Conversation
Introduce one validated source-to-graph boundary, internalize composable block capabilities, and gate config/release packaging with compatible provenance.
feat(dag): unify workflow authoring and blocks
…(CI-LOCK-02) A clean checkout could not reproduce dependencies: packages/app/package.json pinned ghostty-web to the mutable `#main` ref while bun.lock recorded a stale commit (513463a), so `bun install --frozen-lockfile` failed with 'lockfile had changes'. CI also ran non-frozen installs via the shared setup-bun action, so it could silently test a different dependency set than the lock declared. Fixes: - Pin ghostty-web to the reviewed immutable commit 83c0a07b8628b748aed073b232cb4b52a6ca11c1. - Sync bun.lock to that pin (manifest + lock entry). - Add `--frozen-lockfile` to both setup-bun installs (Linux + Windows) and to the release-fork package-templates install. - Add a static repository-policy gate (test/policy/repo-dependencies.test.ts) that fails closed if any git dependency is not pinned to a full 40-char SHA, or any `bun install` in CI (.github/**/*.yml) is not frozen. It validates config only — no second install flow. Mutation-proven: reverting the SHA to `#main`, or dropping `--frozen-lockfile` from any CI install, flips the gate Red. Clean checkout `bun install --frozen-lockfile` now passes with a stable lock. Co-Authored-By: Claude <noreply@anthropic.com>
fix(ci): pin ghostty-web to immutable SHA and freeze all CI installs (CI-LOCK-02)
Phased reconstruction of the lost ProjectMemoryAuthority redesign on top of the d7b011738 process-safe baseline. 8 phases (P1-P8), each a commit. P1=identity+atomic store API, P6=fromDirectory cutover (1C), P7=crash harness (1A). Co-Authored-By: Claude <noreply@anthropic.com>
…rotocol Adversarial review (5 critics, ultracode) found the lost ADR-0004 is unrecoverable, so 'faithful reconstruction' is unverifiable. Add blocking product decisions D1-D4 (Policy-in-Home vs ADR-0001, source-Home preserve retention, retirement-as-merge/lineage, new journal/Revision/ChangeMemory surface) requiring user sign-off, a mandatory P0 (recreate ADR-0004 + CONTEXT for approval before P1), technical revisions (P4 before P3, FK per-table rules, mutation-gate fixes, ledger locations, phase enum), and a resume protocol for fresh sessions. Co-Authored-By: Claude <noreply@anthropic.com>
…osed) P0 design-of-record for the ProjectMemoryAuthority redo, reconstructing the lost ADR-0004 (uncommitted WIP, /tmp-cleaned). Resolves the §7.A product decisions D1-D4 and encodes the user principles confirmed 2026-08-12: one shared Memory per Project (worktrees share it, no per-worktree memory), Memory never forks, and identity upgrade is imperceptible. Status: Proposed (P0 gate) — P1 is blocked until the user approves this ADR. Co-Authored-By: Claude <noreply@anthropic.com>
Fix #1) migrateProjectId deleted the old ProjectTable row, which ON DELETE CASCADE silently destroyed every DAG workflow and every saved permission whenever a repo gained its first remote (root -> first-remote identity upgrade). Repoint both project_id FKs inside the existing immediate transaction before the old row is deleted. A (newID, action, resource) collision on permission fails the transaction closed (no data loss). Extended 'migrates cached root project data when origin becomes available' to seed a workflow + permission and assert both survive the upgrade. Mutation gate: removing the repointing flips the test Red (rows cascade-deleted). Co-Authored-By: Claude <noreply@anthropic.com>
…04 redesign User applied Occam's Razor: the 8-phase ProjectMemoryAuthority redesign is over-engineered for the real needs (shared/no-fork memory already in baseline; imperceptible upgrade + no data loss via small in-place fixes). ADR-0004 → Rejected. Plan §10 = 4 targeted fixes; Fix #1 already done. Co-Authored-By: Claude <noreply@anthropic.com>
…h complete Re-assessed the remaining Occam fixes against the actual code: - #3 (ABBA): unreachable — migrateHome is one-way (root→remote), no reverse caller, so the two project flocks are never taken in opposite orders. - #4 (destructive rescan): already handled — worktree reconcileLegacyMemory invalidates the admission cache before ensure, forcing a fresh rescan. - #2 (typed errors): deferred — full propagation is a multi-file cascade for a marginal HTTP-status gain on a rare conflict (.orDie preserves the diagnostic in the Die cause). Awaits user decision (Occam cut vs invariant #5). Fix #1 (the real data-loss gap) stands; full regression green. Co-Authored-By: Claude <noreply@anthropic.com>
…entity (MEM-PR01-00) Every commit-less repository resolves to the same ProjectV2.ID.global, and the branch keys Memory Home by project ID. Before this change an enabled global config activated Memory for all commit-less repos at once: one shared Home leaked topics across unrelated repositories, and the first commit moved the identity to root/remote while migrateProjectId never migrates away from global — silently orphaning everything written pre-commit. Fix with the minimal Occam seam: one fail-closed guard in Memory.configuration (the single activation gate behind active/prepare/search/checkpoint/setEnabled) returning undefined while the project identity is global. Memory activates normally once the repository gains a real identity; migrating the shared bucket is structurally infeasible (no per-repo provenance) and pre-existing orphans belong to the deferred retention/GC decision. - Red: search must report "unavailable" and /memory on must stay off for a commit-less repo even with an enabled global config and seeded topics - Green: single guard; identity-scoped (repos with a commit activate normally) - Mutation: removing the guard turns both Red tests red again - Domain regression: memory+project suites 162 pass / 0 fail; opencode+core typecheck clean - redo plan: record Fix #5 decision; reopen #3 (ABBA reachable via remote→remote identity change, MEM-PR01-R1-24) - remove leftover no-assertion diagnostic scaffold (repro-scope-finding); its scenario is captured in finding MEM-PR01-R1-06 for the M-C slice Co-Authored-By: Claude <noreply@anthropic.com>
…erge, FK collision, deadlock-freedom (MEM-PR01 M-A)
Two-round review confirmed four P2 defects in the identity-upgrade path; all
fixed at the existing seams with Red→Green→mutation evidence per finding.
R1-12 inspectHome wedged every upgrade after a crash: the store's own
atomicWrite residue (manifest.json.<pid>.<uuid>.tmp) was rejected as
foreign state. Tolerate the store's own temp pattern; foreign files
still fail closed (pinned).
R1-15 The merge compared full topic JSON, so controller metadata drift from
MemoryStore.markMatched (last_matched_at/match_count/revision/
updated_at) registered as a user-visible ConflictError and wedged the
upgrade. Compare content only; the target's own copy stays
authoritative; real content differences still conflict (pinned).
R1-11 Permission FK repoint used a bulk UPDATE that violated the unique
(project_id, action, resource) index whenever the successor identity
already held the same (action, resource) — the immediate transaction
died and the whole upgrade wedged on every retry. Repoint per row;
on collision the successor row wins and the duplicate old row is
dropped; disjoint rows still repoint.
R1-24 The redo plan claimed ABBA unreachable because retirement was "one-way"
— false: a changed origin URL yields remote→remote transitions, and the
old lock structure (hold flock(old) across the merge while
updateTopics locks flock(new) inside) deadlocks opposite-direction
migrations (Red: 20 s test timeout on the legacy structure). Sorted
pre-acquisition is impossible because the flock is non-reentrant, so
fix by construction: a sorted pair lock serializes the two directions
and the merge is restructured into three phases that never hold more
than one memory-project:* lock at a time. A source that changed
mid-merge now fails closed with retryable SourceChangedError instead
of risking deletion of new data. Crash-retry convergence pinned
(R1-13).
- Domain regression: memory+project suites 169 pass / 0 fail; opencode+core
typecheck clean; lint ratchet unchanged (0 new warnings).
Co-Authored-By: Claude <noreply@anthropic.com>
…t reconcile, TOCTOU revalidation, retired-identity inertness (MEM-PR01 M-C) R1-06 (blocking): worktree remove/reset reconciled admission against a SINGLE directory, so a lone sandbox legacy config could be promoted to the project config past disagreeing siblings (order-dependent, silent effective-config flip). Both call sites now pass the complete snapshot (primary + every registered sandbox); disagreeing siblings fail closed with no promotion. R1-03: configuration() fell back to the stale instance context when the identity row was gone, letting a process holding a retired identity fork a Home under it. The fallback is removed: missing row = inert. R1-04: admission deleted scanned legacy topic/config files without re-reading them; a writer outside the admission flock (older runtime, hand edit) landing between scan and delete lost content. Each file is now re-read and compared immediately before removal; changed content is preserved and surfaced as a conflict. Deterministic TOCTOU test pins the scan→delete window via the store flock. R1-08: worktree remove/reset migration ran for uninitialized projects despite the memory path's inertness rule; reconcile is now gated on time.initialized (residue still fails closed). Existing migration tests stamp initialized. R1-10: admission's explicit-config choice used a localeCompare sort that put memory.json before memory.jsonc, disagreeing with MemoryConfig.load. The scan now keeps loader precedence and a jsonc/json fork in the project directory is diagnosed as config.conflict instead of silently picking a side; legacy configs equal only to the non-effective file are no longer deleted as duplicates. Pins: R1-07 (/memory writes the project config to the project worktree from a non-primary instance context) and R1-23 (runtime admission snapshot covers every registered sandbox). - Domain regression: memory+project suites 176 pass / 0 fail; opencode+core typecheck clean; lint ratchet unchanged (0 new warnings). Co-Authored-By: Claude <noreply@anthropic.com>
…to remove (MEM-PR01 M-D) R1-16 (blocking): list() ran `git worktree prune` and deregistered sandboxes for every merely-prunable entry. "prunable" does not prove a worktree is gone — git also marks inaccessible directories (unmounted volume, locked parent) and broken gitdir links whose directories still exist, so a read call could destroy git admin data and live registrations. list() is now a pure observation path: prunable entries stay hidden from the listing but are otherwise untouched. The destructive cleanup moves to the action path, where each case can be proven: - remove() gains a prunable branch: reconcile legacy memory fail-closed, prune the admin data, remove the directory if it still exists, delete the branch, drop the registration. - remove() gains a git-unknown recovery branch (R1-18): a registered worktree with no git record previously failed forever with a false "not registered" error and no remediation; it now reconciles legacy memory fail-closed and drops the stale registration without ever deleting the directory. - Registration cleanup drops every canonically-equal entry, not just the first — symlinked paths (/var vs /private/var) could register the same worktree twice and leave a zombie entry that broke serialized removal. Pins (both mutation-proven): - R1-17: reset fails closed over invalid legacy memory and preserves it. - R1-19 (blocking): reset/remove invalidate the admission cache before the rescan; a reset-primed clean cache must never hide a legacy file that appears before a later destructive operation. - Updated the prune-era list test to the new semantics (list hides but does not touch; explicit remove cleans up). - Domain regression: memory+project suites 180 pass / 0 fail; opencode+core typecheck clean; lint ratchet unchanged (0 new warnings). Co-Authored-By: Claude <noreply@anthropic.com>
…em_count, torn-commit (MEM-PR01 M-E) R1-02 (P2 test-gap): the corrupt-manifest fail-closed guards had no test, so reverting them would let migrateHome delete an unread Memory Home. Now pinned: an invalid manifest and a manifest referencing a missing generation both fail readSnapshot, and migrateHome fails closed on the merge path with the source Home preserved. Both guards proven load-bearing by mutation (fail-open revert turns the test Red). R1-20 (P3 test-gap): decodeTopic item_count/items.length consistency was only covered by a since-deleted test. Re-pinned at both the decoder and the writeSnapshot generation gate (mutation-proven). R1-21 (P3 test-gap): a crash mid-writeSnapshot leaves an orphaned staging generation whose manifest was never published; pinned that it never shadows the committed generation and the store still commits cleanly (mutation-proven). Test-only change; no production code touched. - memory+project suites 183 pass / 0 fail; opencode+core typecheck clean; lint ratchet unchanged (0 new warnings). Co-Authored-By: Claude <noreply@anthropic.com>
…-process commit conflict (MEM-PR01 M-F) R2-02: the branch collapses MEMORY config onto one project-primary file, written by three paths under mutually disjoint locks — /memory on|off (in-process KeyedMutex), admission promotion (memory-admission flock), and readConfig's normalization rewrite (no lock). atomicWrite prevents torn bytes but not whole-document last-writer-wins across worktrees/processes. All config file writes now serialize on a per-file cross-process flock (memory-config:<file>): writeProject, writeGlobal, and the normalization rewrite. Pinned by a blocking-observation test; mutation-proven (dropping the lock lets a concurrent writer complete while the lock is held). Residual, documented rather than fixed (Occam): decision-level read-modify-write across processes is not CAS-protected — only the write primitives are serialized. A full cross-process RMW protocol would be over-engineering for the exposure. R2-03: the cross-process commit protocol's explicit-conflict guarantee (ADR-0002) was only exercised within one process. A new spawned-worker test commits with a stale expectedRevision from a second OS process and observes CommitConflictError deterministically (the pre-existing updateTopics race test only overlaps probabilistically). - memory+project suites 185 pass / 0 fail; opencode+core typecheck clean; lint ratchet unchanged (0 new warnings). Co-Authored-By: Claude <noreply@anthropic.com>
…esign (MEM-PR01 M-G)
R1-01 (blocking): CONTEXT.md still shipped the rejected ADR-0004 authority
design as the domain's governing self-doc. Rewritten to the actual authority
structure (Store/Config/Admission/migrateHome/worktree guard + project
identity migration): rejected-design glossary and invariants removed (Identity
Alias, Canonical Project ID, tombstone retirement, opaque Revision, destruction
guard); source Home described as migrate-then-remove with retention deferred;
Project Configuration described as the unversioned .opencode/memory.jsonc; the
read-leniency split stated (runtime read projects empty, strict reads and
migration fail closed); ADR-0001's policy clause restored as live; ADR-0004
marked Rejected; the M-A…M-F behaviors reflected (global inertness,
content-only conflicts, non-destructive list, fail-closed reset/remove,
per-file config lock).
R1-25: redo-plan internal consistency — header status no longer says PLANNING;
the §10 resume protocol is marked superseded (no pending autonomous fix, only
user decisions remain).
R1-09 (spec-gap → decision): the git-exclusion narrowing to the two config
candidates is intentional and now documented: preserved fail-closed legacy
topic files stay visible in git status and committable; surfacing repair-
pending files beats silently excluding user data.
R1-14 (spec-gap → requirement): the openspec workspace is untracked, so this
plan now carries the identity-upgrade requirement ("Identity upgrade preserves
Project Memory and Project-owned references") with its scenarios, pinned by the
M-A/M-B/M-C/M-E tests.
Docs-only; no production code touched.
Co-Authored-By: Claude <noreply@anthropic.com>
…oped prune, proof-after-hook (MEM-PR01 M-H) R3-P2-a in-flight old-identity writer could recreate a retired Home: writers (prepare/search/checkpoint) now hold a cross-process memory-identity:<id> flock around their whole read-modify-write and re-check identity liveness inside it; migrateHome takes the same identity lock inside the sorted pair lock, so it waits for in-flight writers and moves their writes with the Home. Lock order admission -> migrate(pair) -> identity -> project is cycle-free. R3-P2-d git worktree prune is repo-global; it now runs only when the removed entry is the sole prunable one (else stale admin data is left for explicit cleanup), so sibling worktrees' admin data is not destroyed. R3-P2-e the WorktreeRemove hook now fires BEFORE the reconcile proof on all remove paths, so the proof observes everything the hook produced. R3-P2-f remove/reset no longer fall back to the stale instance identity (?? ctx.project); they fail closed when the identity row is gone. R3-P2-b cleanupLegacyDirectory re-checks the listing immediately before removing each dir. Pins (mutation-proven): SourceChanged verify-before-delete guard (R3-P2-c); store write paths fail closed on a corrupt manifest (R4-P2-a); unresolved admission results are never cached (R4-P2-b). Docs: ADR-0002 updated to the three-phase merge + identity-lock protocol; rejected ADR-0004 no longer claims to supersede live clauses; redo-plan #3 ABBA narrative corrected. Also drops two no-op non-null assertions (session/summary.ts, format/index.ts) surfaced by type-aware churn from the identity-migration FK fix, returning the tree to the 4852 lint ratchet with no behavior change. Co-Authored-By: Claude <noreply@anthropic.com>
…-PR01 M-I) Closes the two Round 5+6 convergence findings: - P1-a (writer fence in the wrong lock dir): memory.ts's writer fence (prepare/search/checkpoint) now passes home.locks so it lives in the same lock namespace as identity migration. Previously it fell back to the default XDG-state lock dir, a DIFFERENT directory, so the writer fence and the migration fence never actually serialized. - flock-leak P2 (fence released before row deletion): ProjectIdentityMigration .migrate now holds memory-identity:<oldID> for the WHOLE retirement — the Memory Home migration AND the caller's reference/row retirement — via a retireReferences callback. The fence is no longer released between the Home move and the old-row deletion, so an in-flight writer under oldID cannot slip into the gap. Callers pass their row retirement as the callback and no longer touch the fence themselves (single authority for the fence). Mutation check: removing retireReferences() from inside the fence turns the MEM-PR01-R1-11 permission-collision test Red (FK repoint no longer happens), confirming the seam wiring is load-bearing. Co-Authored-By: Claude <noreply@anthropic.com>
…(MEM-PR01 M-J) Round 7 converged on one residual P1 (F1) and one P2 (F2), both pre-existing: - F1 (P1): admission.ensure took the memory-identity fence but never re-checked identity liveness inside it. A retirement could complete while admission waited on the fence, and admission would then import legacy topics into the re-created retired Home AND delete the legacy source files (permanent orphaning — the identity cache already points at the successor, so no migration would ever run for the pair again). - F2 (P2): the phase-1 Home rename raced a concurrent newID writer creating the target between existsSafe and rename (ENOTEMPTY), dying fromDirectory. Self-healing (next boot retries into the merge path), no data loss. The identity-race TOCTOU class has now been found in three consecutive review rounds, and the memory-identity protocol (key + lock dir + in-fence liveness recheck) was hand-duplicated at four sites across three modules — exactly why admission could diverge from the writer discipline. Per the redesign rule this is a seam redesign, not a patch: - New MemoryIdentityFence (memory/identity-fence.ts) is the single authority for the protocol: key() builds the lock key, withLiveIdentity() holds the fence on home.locks AND re-checks the identity row inside the fence (None = retired, callers fail closed). A future path cannot forget the recheck. - Writers (prepare/search/checkpoint) and admission route through it; the retirement seam (ProjectIdentityMigration) stays the only raw fence holder (it deletes the row inside the fence) and builds its key from key(). - admission.ensure now fails with a tagged IdentityRetired error when the row is gone; configuration() stays inert, the worktree guard proceeds (the migration is moot after a completed retirement). - F2: rename failure with a target that appeared falls through to the snapshot-merge path; genuine FS failures still rethrow. Verification: Red test MEM-PR01-R7-F1 (ensure after row retirement must not import nor delete the legacy files) confirmed Red before, Green after; mutation removing the in-fence recheck turns it Red again. memory+project 189 pass / 0 fail; typecheck clean; lint flat at 4852. F2's mutation is registered as a test-gap: the race window is between two file ops with no observable state between them, so no deterministic public-seam test exists (the fallback routes into the already-tested merge path). Co-Authored-By: Claude <noreply@anthropic.com>
…ve (MEM-PR01 M-K) Round 9 product-invariant review found P2-A (introduced by M-J): the reconcileLegacyMemory guard returned undefined on IdentityRetired, so a worktree remove could proceed past the fence and `git worktree remove --force` would destroy legacy .opencode/memory content that was never admitted into any Home. The window sits between removeLocked's own row-liveness check and the admission fence recheck — widened by the WorktreeRemove hook (user scripts) that runs between the two. Fix: on IdentityRetired the guard now returns a blocker message (fail closed), matching the reset path's existing stance. A retry under the successor identity imports the legacy content first and then removes safely. Red-first + mutation evidence: - New test MEM-PR01-R9-P2A (worktree-remove.test.ts): holds the memory-admission flock so the remove blocks inside ensure after its own row check passed, retires the identity row, then releases — asserting the removal fails and the never-admitted legacy file survives. Red before the fix, Green after; reverting the blocker to undefined turns it Red again. Also fixes a standards-P2: reindents the retirement transaction body in project.ts (pure whitespace, no behavior change). Registered, not fixed here (out of PR scope): EffectFlock stale-break can silently lose a cross-process update (P2-B, pre-existing core infra, recorded as a residual for the final audit). Co-Authored-By: Claude <noreply@anthropic.com>
fix(memory): make Project Memory process-safe and identity-safe (MEM-PR-01)
…tity The dev push CI (linux Unit Tests) failed in MEM-PR01-R1-03: the first search returned "unavailable" in isolation while the same file passes locally. Root cause (CI log analysis + code trace): the test's "active" pre-condition depends on the InstanceStore's BOOT-TIME project resolution, which runs in a separate Effect graph (its own :memory: Database) and silently degrades to the shared global identity when a git subprocess fails transiently on a loaded runner (every git failure collapses to "exit 1, empty output" in core git.ts run()). The test body's own resolution milliseconds later returns the real root-commit identity, so the identity assertions pass while memory fails closed against the stale global context. Fix: resolve the identity once in the test body and hand it to the instance store (provideInstance now accepts a full LoadInput; boot skips its own fromDirectory when project+worktree are given). Applied to the three identity-scoped tests (R1-03, R1-23, R1-00 third) that assert active memory. Registered separately (product hardening, out of this PR): identity resolution should not silently degrade to the global identity on transient git errors — discover/rootCommits should retry or propagate instead of collapsing to exit 1. Co-Authored-By: Claude <noreply@anthropic.com>
test(memory): stamp the instance store with the already-resolved identity (CI fix)
Second dev-CI failure investigation (run 31648866555) with the identity-fixture fix in place: the same assertion still failed, still with no visible warning. Verified root cause: bun test runs all files in ONE process, sequentially, sharing one XDG_CONFIG_HOME. A file that runs before this one and triggers global-memory initialization (memory.test prepare, or instance boot via bootstrap.ts memory.init) leaves a VALID global memory.jsonc whose model this file's fake provider does not know. writeGlobal then silently no-ops over the valid file (config.ts returns false), configuration() loads the foreign model, resolveModel fails (the warning is captured by TestConsole and never reaches CI logs), and search fails closed with "unavailable". R1-00's identical flow passes because R1-07's finalizer removes the global file in between. CI-only because bun's file order is deterministic per filesystem state and the fresh CI checkout orders a contaminator before this file. Fix: pin a private OPENCODE_CONFIG_DIR per file (beforeAll/afterAll, live env getter — globalConfigDir reads it at call time) so the global file can never be contaminated by earlier files, plus a tripwire assertion after writeGlobal that the loaded global config carries the expected model. Registered separately (product hardening): writeGlobal should log when it declines to overwrite an existing valid config. Co-Authored-By: Claude <noreply@anthropic.com>
…ation test(memory): isolate the global config dir per test file (CI fix)
…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>
fix(goal): close registration lifecycle, ownership re-trigger, and session-delete cleanup clusters (GOAL-FP-01)
fix(opencode): fence goal and dag automation
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
Promote the validated
devintegration branch tomain.This release includes:
Evidence before promotion
devTypecheck: passedRelease policy
mainPR checks pass.release-forkfrommainonly after the merge.