fix(goal): close registration lifecycle, ownership re-trigger, and session-delete cleanup clusters (GOAL-FP-01) - #235
Merged
Merged
Conversation
…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
force-pushed
the
fix/goal-state-machine
branch
from
August 13, 2026 02:23
7b3c526 to
3c7ae28
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Goal 状态机与 Session 自动化租约的固定点切片(branch
fix/goal-state-machine→dev)。关闭 review-only 矩阵中的注册生命周期、ownership 再触发、session 删除清理三个共享根因簇。
Fixes #230
What changed (by cluster)
启动 wake sweep 只注册非终态 workflow(投递路径自注册已验);终态 handler 补 unregister(key 身份已验证 projector 写
WorkflowTable.id = dagID);无 runtime entry 的终态 workflow 按行 session_id 注销。此前已 reported 的终态 workflow 在重启后永久泄漏 dag 注册 → owner() 偏好 dag → goal 永久卡 active(违反 ADR-0001)。SessionAutomationLease.unregister在 dag owner 转移时复用既有SessionStatus.set(idle)机制再触发 goal 评估(无新消费者;busy 会话门控);blocked-claim 标记保证每被拒 claim 恰一次重试(R1 双 fiber 竞态在源头不可构造)。此前最终 unregister 晚于最后一个 idle 事件 → active goal 静默停滞。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 failbun typecheck(packages/opencode):cleanbun lint:4852(棘轮持平)登记(不在本 PR 修)
🤖 Generated with Claude Code