Skip to content

fix(goal): close registration lifecycle, ownership re-trigger, and session-delete cleanup clusters (GOAL-FP-01) - #235

Merged
LeXwDeX merged 11 commits into
devfrom
fix/goal-state-machine
Aug 13, 2026
Merged

fix(goal): close registration lifecycle, ownership re-trigger, and session-delete cleanup clusters (GOAL-FP-01)#235
LeXwDeX merged 11 commits into
devfrom
fix/goal-state-machine

Conversation

@LeXwDeX

@LeXwDeX LeXwDeX commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

Goal 状态机与 Session 自动化租约的固定点切片(branch fix/goal-state-machinedev)。
关闭 review-only 矩阵中的注册生命周期、ownership 再触发、session 删除清理三个共享根因簇。

Fixes #230

What changed (by cluster)

  • 簇 A — dag lease 注册生命周期绑定 workflow 终态(GOAL-FP-01-01 P1 / -03 P2 / P2-A 残余):
    启动 wake sweep 只注册非终态 workflow(投递路径自注册已验);终态 handler 补 unregister(key 身份已验证 projector 写 WorkflowTable.id = dagID);无 runtime entry 的终态 workflow 按行 session_id 注销。此前已 reported 的终态 workflow 在重启后永久泄漏 dag 注册 → owner() 偏好 dag → goal 永久卡 active(违反 ADR-0001)。
  • 簇 B — ownership 释放后的 idle 再触发(GOAL-FP-01-02 P2 / -11 缓解面 / R1):
    SessionAutomationLease.unregister 在 dag owner 转移时复用既有 SessionStatus.set(idle) 机制再触发 goal 评估(无新消费者;busy 会话门控);blocked-claim 标记保证每被拒 claim 恰一次重试(R1 双 fiber 竞态在源头不可构造)。此前最终 unregister 晚于最后一个 idle 事件 → active goal 静默停滞。
  • 簇 C — Session.remove 统一清理(GOAL-FP-01-05 P2 / -06 P2 / -16 P3):
    Goal/Lease/Dag 改为 Session.layer 硬需求(defaultLayer 自供 + node 列表齐全,typecheck 强制接线——serviceOption 静默 None 类不可构造);删除顺序 goal purge → workflow cancel(既有 Dag.cancel authority)→ lease purgeSession(KeyedMutex 内)→ 行删除 + FK 级联;goal_outcome 同事务删除(Goal.clear 保留历史,新 purgeSession 为删除路径)。此前 CLI session delete 静默孤儿化 goal 行、dag 注册存活到重启后被 re-adopt。

Verification

  • bun test test/session test/goal test/dag:964 pass / 7 skip / 0 fail
  • bun typecheck(packages/opencode):clean
  • bun lint:4852(棘轮持平)
  • Red/mutation 证据:每簇 Red 先行(-01/-03/P2-A/R1/-05/-06/-16 各测试),关键修复 mutation 均转 Red 后恢复

登记(不在本 PR 修)

  • GOAL-FP-01-04(P2 pre-existing):GoalLoop 无启动扫描,crash 前 active 的 goal 休眠到下次交互——簇 D 后续
  • GOAL-FP-01-11 P3 本体 / -12 ~ -15 P3 卫生项——簇 E 后续
  • test-gap:R1 的 4a 中断变体经公共 seam 不可确定性钉住(测试钉共享内核:每边界重复评估)

🤖 Generated with Claude Code

LeXwDeX and others added 11 commits August 13, 2026 09:19
…state (GOAL-FP-01-01/-03)

The DAG automation-lease registration lifecycle was bound to WAKE DELIVERY
instead of workflow state, leaking dag registrations that permanently block
the session's goal (owner() prefers dag, so the goal can never claim).

- Startup wake sweep registered every workflow in the wake snapshot,
  including terminal workflows with wake_reported=true, which are never in
  the wake batch and therefore never unregistered (-01).
- Terminal event handlers (WorkflowCompleted/Failed/Cancelled) never
  unregistered, so a workflow terminalizing without a successful wake
  delivery kept its registration indefinitely (-03).

Fix:
- Sweep: register only non-terminal workflows. Verified safe for
  terminal-but-unreported workflows: tryDeliverWake registers every
  workflow in its batch itself right before claiming the wake lease, so
  redelivery does not depend on the sweep.
- Terminal handlers: unregister the dag registration on workflow
  terminalization. Identity verified: the projector writes
  WorkflowTable.id = event dagID, so the unregister key
  { kind: "dag", id: evt.data.dagID } matches every registration key
  (adoption, recovery, sweep, delivery).

TDD evidence (test/dag/dag-lease-lifecycle.test.ts, real DagLoop init over
in-memory DB + real SessionAutomationLease + real Goal/store):
- Red (current code): -01 "goal claimable after restart" failed with
  claim(goal) = none (dag leaked by the sweep); -03 "dag lease released on
  terminal event without wake delivery" timed out (registration persisted).
- Green after fix: 2/2 pass.
- Mutation 1 (revert sweep filter): -01 goes Red. Restored.
- Mutation 2 (remove handler unregister): -03 goes Red. Restored.

Verification: bun test test/goal test/session/automation-lease.test.ts
test/dag → 564 pass / 0 fail; bun typecheck clean; bun lint → 4852
warnings (ratchet unchanged, 0 new).

Co-Authored-By: Claude <noreply@anthropic.com>
…ry (GOAL-FP-01-03 follow-up)

P2-A residual on the -01/-03 seam: the terminal-handler unregister was gated
by Stream.filter(runtimes.has(dagID)), so a workflow registered by the
startup wake sweep but never adopted into a runtime entry (recoverWorkflow
aborted at startup, e.g. an unreadable persisted row) could only ever be
unregistered by a successful wake delivery — a control-op terminalization
left a permanent dag registration and the goal permanently blocked.

Fix (same seam, loop.ts only):
- Terminal handlers no longer filter on runtimes.has. The handler remains a
  no-op for events not concerning this instance: the evalLock cleanup and
  the wake fork stay gated on the runtime entry, and the new no-entry
  release is scoped by the durable row's project (the same cross-instance
  guard every adoption path uses).
- When the terminal event has no runtime entry, the handler releases the
  registration from the durable row: store.getWorkflow(dagID) →
  WorkflowRow.sessionId (verified: DagStore.Interface.getWorkflow returns
  WorkflowRow with sessionId — no store changes needed), then
  automation.unregister(SessionID.make(wf.sessionId), { kind: "dag",
  id: dagID }) with the project guard.

TDD evidence (test/dag/dag-lease-lifecycle.test.ts, same real-DagLoop
harness):
- Red: new test "releases a swept registration when a workflow with no
  runtime entry is terminalized by a control op" timed out — the dag lease
  survived WorkflowCancelled (the recovery failure is simulated as a
  session-store defect that aborts reconcileWorkflow, leaving a non-terminal
  row with no runtime entry; sweep registers it; dag.cancel terminalizes it).
- Green after fix: 3/3 in the file.
- Mutation (remove the no-entry unregister branch): the new test goes Red
  (timeout). Restored.

Verification: bun test test/dag test/session/automation-lease.test.ts
test/goal → 565 pass / 0 fail; bun typecheck clean; bun lint → 4852
warnings (ratchet unchanged, 0 new).

Co-Authored-By: Claude <noreply@anthropic.com>
…AL-FP-01-02)

The final DAG lease unregister (U2) lands AFTER the last wake turn's idle
event: the runner emits the session idle status before completing its
awaiter, so GoalLoop's idle-driven claim still sees the dag registration and
yields; after U2 lands there is no second idle and the active goal silently
stalls until the next external idle.

SessionAutomationLease.unregister now detects the dag -> goal/none owner
transition (before/after compare under the per-session KeyedMutex, generation
bump semantics preserved) and re-triggers the goal evaluation by reusing the
EXISTING idle status event mechanism (SessionStatus.set idle) — no new event
or GoalLoop consumer. The publish runs after the lock (unconditional
fire-and-forget enqueue, cannot lose or duplicate; Set.delete is idempotent
and only the last dag removal flips the owner). A busy-session gate avoids
spurious judge calls mid-turn: a busy turn always re-emits idle on
completion, which re-drives the claim with the dag already released. This is
also the GOAL-FP-01-11 mitigation surface: a claim that lost the ownership
race gets another chance once the owner actually transfers.

TDD evidence:
- Red: test/dag/dag-goal-wake-retrigger.test.ts fails on pre-fix code with
  "goal was not re-evaluated after the dag lease release (GOAL-FP-01-02)"
  after the workflow completes and the wake is reported, no further idle
  events published (saved /tmp/red-goal-fp-01-02.txt).
- Green: real DagLoop wake delivery end-to-end (U2 fires in the delivery
  tap) + real GoalLoop on the shared bus; goal claimed, judge runs,
  turns_used advances, continuation dispatched.
- Mutation: reverting the unregister re-trigger makes the test Red again
  (saved /tmp/mutation-red-goal-fp-01-02.txt); restored to Green.
- e2e-loop "DAG owner arbitration" updated to the new contract: the dag
  release alone re-drives the goal (manual second idle publish removed);
  its SessionStatus wiring switched to provideMerge so the lease re-trigger
  is visible from the test body context.

Verification: bun test test/dag test/goal test/session/automation-lease.test.ts
= 566 pass / 0 fail; bun typecheck (tsgo --noEmit) clean; bun lint = 4852
warnings (at the ratchet threshold, 0 errors).

Co-Authored-By: Claude <noreply@anthropic.com>
…AL-FP-01-02 follow-up)

R1: the GOAL-FP-01-02 unregister re-trigger publishes a duplicate idle for
every dag release, so the turn-idle fiber B (whose claim landed after U2) and
the retry fiber D both hold valid same-generation goal tokens. The harmful
interleavings on the synthetic no-text verdict path: 4a — B commits and is
interrupted by D's registerLoopFiber between commit and continuation
dispatch, D's stale-revision commit noops, goal silently stalls; 4b — D
double-commits (turns inflation) or spurious-pauses on the busy status check.

Candidate analysis: (a) per-session serialization of afterIdle alone still
lets the second fiber commit again after the first dispatched (4b survives);
(c) generation bump on goal re-register invalidates only the OTHER fiber's
token — the revision guard still admits D's fresh-load commit (inflation) and
does not stop the interrupt from killing B post-commit (4a survives);
skip-if-alive on the fiber map races the fiber's unwinding window (branch-4
contract). Chosen fix (b): a per-session blocked-claim flag in the lease.

Mechanism: claim records "a goal claim was rejected by the dag owner"
(blockedGoalClaims); a successful (or non-dag-rejected) goal claim clears it;
unregister CONSUMES it (Set.delete) inside the same per-session KeyedMutex
critical section as the owner-transition decision, so the re-trigger fires
exactly once per blocked claim, atomically with claim serialization. The
blocked claim's evaluation fiber yields at the claim itself, so the retry it
spawns is the only evaluation in flight.

Unconstructibility arguments:
- 4a: D is forked only if the flag was set, i.e. only after an evaluation's
  claim was rejected and that fiber yielded at the claim. B in flight
  post-commit implies B's claim succeeded, which cleared the flag under the
  same lock before U2's consume — no publish, no D, no interrupt. The
  commit→dispatch tail of the sole evaluation can no longer be raced.
- 4b: D implies the flag was set and not cleared since, so no evaluation
  committed in between; D loads fresh state and commits once. A turn-boundary
  fiber whose claim succeeds clears the flag before any release decision, so
  one commit per boundary. The busy→pause path is unreachable for D (no turn
  is in flight when D runs).

No loss: the retry obligation is only dropped by a successful claim (the
evaluation then happened) or by the busy-gate consume — whose session
re-emits idle on turn completion and re-drives the claim (runner onIdle →
SessionStatus.set idle).

TDD evidence:
- Red: new e2e-loop test "an unblocked goal is evaluated exactly once when
  the dag releases before the boundary idle" fails deterministically on the
  unfixed re-trigger with turns_used 2 for one real boundary (Expected: 1,
  Received: 2), pinned by a second-dispatch gate — saved
  /tmp/red-goal-r1.txt.
- Green: real GoalLoop + real lease + synthetic no-text verdict; the dag
  release stays silent when no claim was ever blocked, the boundary
  evaluation commits exactly once.
- Mutation: reverting the blocked-claim gate to the unconditional publish
  makes the test Red again (Expected: 1, Received: 2) — saved
  /tmp/mutation-red-goal-r1.txt — then restored.
- The GOAL-FP-01-02 dag wake test now reproduces the faithful production
  sequence: the prompt mock emits the wake turn's idle event (as the real
  runner does before its awaiter resolves), the blocked claim arms the
  re-trigger, and U2's retry drives the goal with no idle after U2.

Verification: bun test test/dag test/goal test/session/automation-lease.test.ts
= 567 pass / 0 fail; bun typecheck (tsgo --noEmit) clean; bun lint = 4852
warnings (at the ratchet threshold, 0 errors).

Co-Authored-By: Claude <noreply@anthropic.com>
…elete (GOAL-FP-01-05/-06/-16)

TDD: red test first (test/session/session-remove-cleanup.test.ts, 3 fail on
current code), minimal green, mutation (revert Session.defaultLayer cleanup
provides -> 3 fail), restore -> green.

Wiring diagnosis (-05): Session.remove resolved Goal via
Effect.serviceOption(Goal.Service) captured at layer construction. In the
production AppLayer (effect/app-runtime.ts) Goal.defaultLayer and
Session.defaultLayer are Layer.mergeAll siblings; mergeAll builds members
concurrently against the parent context only, so Goal was never in Session's
build context and the cleanup silently no-op'd - `opencode session delete`
orphaned the goal_state row. Fixed by making Goal, SessionAutomationLease and
Dag hard requirements of Session.layer: Session.defaultLayer self-provides all
three (each is self-contained, requirements=never), Session.node lists their
nodes, and tsgo now enforces the wiring at every composition site (4 raw-layer
test harnesses updated). No layer cycle: Goal -> SessionStatus/Lease,
Dag -> DagStore/DagProjector, none depends on Session.

Cleanup (-06): Session.remove now (1) purges goal rows via Goal.purgeSession,
(2) cancels owned non-terminal workflows via the existing Dag.cancel authority
(durable terminalization; the DagLoop terminal handler aborts child sessions
and releases the dag lease - no second runtime authority), and (3) purges the
session's lease registrations via the new SessionAutomationLease.purgeSession
(under the per-session KeyedMutex). Each step catches its cause and logs a
warning; deletion itself still cannot fail.

Ordering + crash window: cleanup runs BEFORE the Deleted publish (the
SessionProjector deletes the session row inside that transaction; FK cascade
then wipes workflow rows). A crash mid-way leaves a live session with no
goal/workflows (consistent, recoverable) - never orphan goal rows or
re-adoptable workflows under a deleted session. No shared transaction exists
(three separate aggregates: goal tables, workflow events, lease map); each
step is individually atomic.

-16: goal_outcome rows now deleted in the same durable transition transaction
as the goal_state row (transition seam gained a deleteOutcomes flag;
Goal.purgeSession sets it, Goal.clear keeps outcome history).

Verification: bun test test/session test/goal test/dag -> 964 pass, 0 fail;
bun typecheck clean; bun lint 4852 (ratchet).

Co-Authored-By: Claude <noreply@anthropic.com>
…-01-05 follow-up)

P2-A (ordering inversion): Session.remove published the Deleted event BEFORE
the cleanup block, contradicting the block's own comment. Inside the publish
transaction the SessionProjector deletes the session row and the workflow FK
cascade wipes the workflow rows, so dag.store.listBySession in the cleanup
always returned [] — the cancel loop was dead code, the WorkflowCancelled
event never fired, the DagLoop terminal handler never aborted running DAG
child sessions, and a crash between publish and cleanup orphaned
goal_state/goal_outcome rows.

Fix: reordered remove() to goal purge -> workflow cancel -> lease purge ->
Deleted publish -> event-log removal. The SettingsHook SessionEnd trigger now
runs BEFORE the destructive steps (its documented contract is to observe the
session before removal; the event-wiring test asserts trigger contents only,
no Deleted-vs-hook ordering, so no consumer conflict).

P2-B (vacuous assertion): the cancellation test asserted
expect(cancelledEvent).not.toBeNull(), which passes vacuously — drizzle
.get() returns undefined for a missing row. Changed to toBeDefined().

TDD evidence:
- Red (vacuity proof): toBeDefined() on the publish-first code fails with
  Received: undefined — the cancel event was indeed absent (2 pass / 1 fail).
- Green: after the reorder, the event is actually present (3 pass / 0 fail).
- Mutation: moved the publish back before the cleanup -> 1 fail (event
  absent). Restored -> green.

Verification: bun test test/session test/goal test/dag -> 964 pass, 0 fail;
bun typecheck clean; bun lint 4852 (ratchet).

Co-Authored-By: Claude <noreply@anthropic.com>
GoalLoop was purely event-driven: the idle-status subscription was the
only driver, and no component emits idle for sessions that already
existed at startup. An active goal that survived a crash slept until
the next user interaction; with turns_used > 0 the D6 zombie guard also
never fired (it runs inside afterIdle). The automation obligation — an
active goal keeps advancing — was lost across restart.

Add a startup scan to GoalLoop.init:

- The durable snapshot is captured at instance boot inside the
  InstanceState builder (Goal.listActiveSessions — new accessor
  returning session ids whose goal_state row is "active", plus the
  goal revision), then the per-session triggers are forkScoped after
  the idle subscription is armed. Building from init's caller context
  would not work: evaluation fibers resolve services from their
  ambient runtime context, and the builder runs under the ScopedCache
  layer-build environment — the same context the idle subscription
  sees (this is why the test-injected GoalLoopJudgeLLM is visible).
- The scan reuses the EXISTING evaluation path verbatim — the idle
  handler body was extracted into triggerEvaluation (active pre-check,
  fork afterIdle, registerLoopFiber, identity-scoped self-clean) and
  is now shared by both drivers. No new evaluation logic.
- Mutual exclusion stays with the lease claim: a dag-owned session is
  rejected inside afterIdle exactly as on a real idle, and the
  GOAL-FP-01-02 blocked-claim re-trigger re-evaluates it once the dag
  releases (harmless + self-healing; covered by a test).
- Busy sessions are gated via SessionStatus exactly like the idle
  path (the automation-lease re-trigger gate), plus afterIdle's
  post-judge status check and promptIfIdle; covered by a test.
- Crash window between snapshot and trigger: terminal changes are
  absorbed by the active-status re-check; non-terminal changes (the
  scan fiber scheduled late, after the session's own idle event
  already evaluated the boundary) are absorbed by the expectedRevision
  gate — revision bumps on every durable transition, so a stale
  trigger cannot double-commit turns (the R1 turns-inflation harm,
  caught by the existing dag-release test before the gate existed).
- The scan runs once, forkScoped; query and per-session failures are
  logged and swallowed, never fatal to init.

TDD: Red — seeded a goal in the durable store before boot, published
ZERO idle/status events, polled 5s for the judge/continuation: 3 tests
failed with "startup scan never evaluated … (5s timeout)", goal stayed
dormant. Green — added the scan; all 3 pass. Mutation — removed the
scan trigger: same 3 tests go Red; restored → green.

Verified: bun test test/goal test/dag
test/session/automation-lease.test.ts (570 pass, 0 fail),
bun typecheck (packages/opencode) clean, bun lint 4852 warnings (≤ 4852).

Co-Authored-By: Claude <noreply@anthropic.com>
…n failure handling (GOAL-FP-01-04 follow-up)

Domain review of the GOAL-FP-01-04 startup scan: one P1 (D-1) and three
P2s (D-2/D-3/D-4), plus one registered residual.

D-1 (P1): the scan was not scoped to the instance. goal_state has no
directory column and the Database is the shared global opencode.db, so
any instance boot evaluated/committed/paused/drove the active goals of
EVERY project — judge budget burn, pause prompts injected into foreign
sessions, cross-project agent turns with the wrong cwd. Fix: the scan
query (Goal.listActiveSessions) now inner-joins goal_state.session_id
→ session.id and filters session.directory = the instance directory.
The session table (core session sql, directory column) is the single
directory authority; no schema change was needed. The directory is
resolved in GoalLoop.init from the caller context (InstanceRef, which
instance boot provides) and handed to the instance-state builder via a
ref set before the first InstanceState.get — the builder runs under the
ScopedCache layer-build environment, which does NOT include InstanceRef
in production (reading InstanceState.directory there would die).

D-2 (P2): "query failures logged and swallowed, never fatal" was false.
tapError/orElseSucceed only handle Cause.Fail, so a boot-time DB defect
killed the state builder, closing the ScopedCache entry scope and
taking the idle subscription down with it until restart. Fix: the query
is wrapped in Effect.catchCause, which in this effect version catches
Fail AND Defect (there is no catchAllCause) — any failure degrades to
no-scan + a log. Per-session triggers keep their catchCause guards.

D-3 (P2): undecodable goal_state rows were skipped silently. The skip
now logs a warning with the session id and the decode error, so the
dormancy is visible (asserted via TestConsole).

D-4 (P2): the boot-snapshot revision gate was not airtight: if an idle
evaluation committed between the scan's gate load and its afterIdle
entry load, the scan's evaluation would commit again (matchesExpected
passes on the re-loaded revision) — double-commit of the same boundary.
Fix: replaced the snapshot-revision comparison with a per-process
evaluatedRevisions map — afterIdle records the committed revision on
every successful updateAfterJudge; the scan path (triggerEvaluation
gate + afterIdle entry gate, flagged by scanResume) skips when the
recorded revision equals the current revision. The idle path never
consults the gate, so it keeps re-evaluating the same revision across
new turn boundaries. This also fixes the D-5 cross-process false
negative: a revision bumped by a touch-without-evaluation (incl. by
another process before this boot) no longer suppresses the resume.
The map is overwritten by every commit and deleted at the same terminal
points where afterIdle unregisters the goal automation.

D-4 testability: the A-commits/B-scan interleaving is not
deterministically constructible through the public seam — the scan's
gate load and afterIdle's entry load are adjacent in the same fiber
with no injectable pause between them, and the fiber map's
interrupt-on-replace kills any earlier evaluation a test could park.
The committed D-4 test instead deterministically parks the scan's
evaluation at the judge (Deferred, not sleep), races an idle evaluation
into the same boundary, and asserts exactly one commit — the tightest
public-seam construction of the race. The record gate's exact
interleaving is argued above rather than exercised.

Registered residual (not fixable in-process): the cross-process mirror
of D-4 — two live GoalLoop instances in the same process group could
both evaluate the same boundary (each has its own record map). Trigger
conditions: two instances booted against the same session/goal_state
simultaneously. Rare; would need a cross-instance lease or a
directory-level claim, out of scope for this slice.

TDD: D-1 Red — a foreign-directory goal got evaluated (foreignJudgeCalls
1, turns 1, expected 0); D-2 Red — dropping goal_state killed init
(test body died); D-3 Red — no skip log captured. D-4 regression guard
green on HEAD. Green after the fix: all four pass. Mutations: removed
the directory filter → D-1 Red; restored tapError/orElseSucceed +
orDie (pre-fix defect channel) → D-2 Red. Restored → green.

Verified: bun test test/goal test/dag
test/session/automation-lease.test.ts (574 pass, 0 fail, 3x stable
e2e-loop reruns), bun typecheck (packages/opencode) clean, bun lint
4852 warnings (≤ 4852).

Co-Authored-By: Claude <noreply@anthropic.com>
…14/-15)

- GOAL-FP-01-07: updateAfterJudge `expected` (goalID+revision) is now a
  required parameter — the stale-judge protection is the contract, not a
  caller convention. matchesExpected's optional short-circuit is gone;
  typecheck enforces every caller passes the pre-judge identity.
- GOAL-FP-01-08: Goal.set now unregisters the previous goal id from the
  automation lease atomically with the overwrite, so a replaced goal can no
  longer leave a double id in the registration set (owner() returned the
  stale first id and silently starved the new goal's claim).
- GOAL-FP-01-09: the goal tool's `complete` no longer shows "✓ 目标已达成"
  when markDone no-ops (clear/complete race) — it reports the no-op instead
  of presenting a goal that no longer exists as achieved.
- GOAL-FP-01-12: the dispatch-failure path now pauses via pauseGoal
  (pauseAndPublish + inline lease unregister), symmetric with every other
  pause site instead of depending on the trailing afterDispatch load.
- GOAL-FP-01-13 (test): one integration test drives the goal continuation
  through the REAL SessionRunState.startIfIdle admission gate — real busy
  flip, real admission rejection, and the REAL Runner onIdle re-driving the
  loop to done with no manual idle events. Remains mocked: SessionPrompt
  admitPrompt/runLoop (full app layer — disproportionate), Session,
  Provider, judge LLM.
- GOAL-FP-01-14: wake delivery dedupes on retry — a summary whose transcript
  part was already written is only re-marked, never re-prompted (in-process;
  the crash-between-write-and-mark residual on the restart sweep is
  registered — a durable delivering-marker would need a schema change). The
  delivery failure log now carries the cause.
- GOAL-FP-01-15: the done confirmation prompt failure is logged instead of
  silently swallowed (no retry — a retried line could re-inject after a new
  goal is set; the crash-window transcript loss is inherent to the
  durable-leads-presentation invariant and the event stream still notifies
  consumers).

Tests: red-first pinning tests for -08 (lease), -09 (tool API), -12
(lease after a defecting trailing load), -14 (wake retry dedupe); -13's test
is the artifact. Mutation-verified for -08/-09/-12/-14.

Co-Authored-By: Claude <noreply@anthropic.com>
…fensive scan ref, lease SessionStatus requirement

Standards deep review follow-ups on the GOAL-FP-01-04 startup scan and
the GOAL-FP-01-02 dag-release re-trigger (S-1..S-3, all P2).

S-1: missing session.directory index. The boot scan joins goal_state →
session filtered on session.directory, so every instance boot linearly
scanned the whole channel-global session table. Added the inline
index("session_directory_idx").on(table.directory) to the session table
definition and generated the migration + snapshot via the sanctioned
generator (packages/core/script/migration.ts): new migration file
20260813020344_bored_skaar, schema.json / schema.gen.ts /
migration.gen.ts regenerated; `bun run script/migration.ts --check` is
clean. The regeneration also reconciled pre-existing snapshot drift:
the hand-written 20260811060000_goal_outcome migration had never been
baked into schema.json/schema.gen (the check was already red at HEAD);
the generator's duplicate of it was discarded so existing installs
never re-run the DDL.

S-2: scanDirectoryRef fragile-by-construction. The unset-ref invariant
lives only in the init→builder call order. The builder now reads the
ref defensively: if it is unset at build time, log an ERROR and skip
the scan (loud no-op) instead of querying with an empty directory that
silently matches no session. The alternative (threading the directory
through the ScopedCache key or InstanceState.make input) would require
modifying shared instance-state.ts beyond the listed files; the
defensive read is the accepted fallback. Not covered by a test: the
unset path is unreachable through the public seam — init sets the ref
before the only call site of InstanceState.get — so no injectable
unset-ref path exists without exposing internals.

S-3: serviceOption(SessionStatus) unsanctioned in the lease. The
dag-release re-trigger silently degraded to a dropped re-trigger when
SessionStatus was absent. SessionStatus.Service is now a HARD
requirement of the lease layer (Layer.sync → Layer.effect; the
serviceOption/None branch is gone); defaultLayer self-provides
SessionStatus.defaultLayer, and the node lists SessionStatus.node
(added to Session's node list — the documented "missing wire fails
silently" invariant). All production and test consumers already build
via defaultLayer, so only the standalone lease test needed wiring; it
now also gains a re-trigger test asserting the blocked goal claim is
re-driven through the real SessionStatus idle publish (typed via the
event definition's data schema, no unsafe assertions).

Verified: bun test test/goal test/dag
test/session/automation-lease.test.ts (579 pass, 0 fail) and
bun test test/session (408 pass, 0 fail), bun typecheck clean,
bun lint 4852 warnings (≤ 4852).

Co-Authored-By: Claude <noreply@anthropic.com>
@LeXwDeX
LeXwDeX force-pushed the fix/goal-state-machine branch from 7b3c526 to 3c7ae28 Compare August 13, 2026 02:23
@LeXwDeX
LeXwDeX merged commit 4fc8515 into dev Aug 13, 2026
5 checks passed
@LeXwDeX
LeXwDeX deleted the fix/goal-state-machine branch August 13, 2026 05:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant