@coralai/sps-cli 0.23.12 → 0.23.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  > **中文文档**: See `README-CN.md` in the source repository for Chinese documentation.
6
6
 
7
- **v0.23.12**
7
+ **v0.23.13**
8
8
 
9
9
  SPS (Smart Pipeline System) is a fully automated development pipeline CLI tool driven by AI Agents. From task card creation to code merging, the entire process runs unattended.
10
10
 
@@ -121,9 +121,9 @@ Planning -> Backlog -> Todo -> Inprogress -> Done
121
121
  | Todo -> Inprogress | ExecutionEngine | Assign Worker slot, build task context, launch AI Worker |
122
122
  | Inprogress -> Done | PostActions + MergeMutex | Detect Worker completion, serialize merge to target branch, release resources, clean up worktree |
123
123
 
124
- The Worker no longer executes `.sps/merge.sh` as the normal path. In `MR_MODE=none`, the Worker commits and pushes the feature branch, then SPS closeout performs a serialized merge. Final integration now runs inside a temporary detached merge worktree, which avoids `main already used by worktree` failures and keeps the user's main checkout untouched. `.sps/merge.sh` remains only as a manual fallback. See `docs/design/10-acp-worker-runtime-design.md` for the persistent Agent transport model, the full worker state breakdown, and the local same-user OAuth reuse boundary. See `docs/design/11-runtime-state-authority-and-recovery-redesign.md` for the follow-up redesign that demotes `state.json` / `acp-state.json` to projections and re-centers recovery around PM state plus worktree/git evidence.
124
+ The Worker no longer executes `.sps/merge.sh` as the normal path. In `MR_MODE=none`, the Worker commits and pushes the feature branch, then SPS closeout performs a serialized merge. Final integration now runs inside a temporary detached merge worktree, which avoids `main already used by worktree` failures and keeps the user's main checkout untouched. `.sps/merge.sh` remains only as a manual fallback. See `docs/design/10-acp-worker-runtime-design.md` for the persistent Agent transport model, the full worker state breakdown, and the local same-user OAuth reuse boundary. See `docs/design/11-runtime-state-authority-and-recovery-redesign.md` for the redesign that demotes `state.json` / `acp-state.json` to projections and re-centers recovery around PM state plus worktree/git evidence. See `docs/design/12-unified-runtime-state-machine.md` for the next-step module cleanup plan that converges `Execution`, `Recovery`, `Monitor`, `Closeout`, and `PostActions` into one state machine authority plus a single runtime coordinator/write path.
125
125
 
126
- PTY transport now extends the same session/run model into the default CLI-backed worker path. When `WORKER_TRANSPORT=pty` or `WORKER_TRANSPORT=acp`, `sps tick` launches work through `sessionId/runId`, persists that state into `runtime/state.json` plus `runtime/acp-state.json`, and lets recovery/status/dashboard inspect the live session/run state. `PostActions` retry/conflict flows and `CloseoutEngine` autofix/conflict flows now resume the same session when possible and automatically rebuild a fresh persistent session when the original one is gone before a retry or merge-conflict follow-up run. v0.23.5 upgrades `node-pty` to a macOS-safe build, auto-restores missing execute permissions on `spawn-helper`, and auto-skips the benign Codex update notice during PTY boot so `ensureSession()` can reach `ready` on fresh launches instead of failing with `posix_spawnp failed`. v0.23.6 further fixes PTY run lifecycle tracking so newly submitted conflict-resolution runs no longer get marked `completed` during the first inspect cycle before the CLI actually leaves the prompt. v0.23.7 hardens `sps worker dashboard` itself by switching PTY/TUI panels from raw pane dumps to structured worker summaries, preventing Codex/Claude full-screen redraw artifacts from corrupting the dashboard layout while also counting PTY-backed active sessions correctly in the summary bar. v0.23.9 now validates persisted PTY sessions by PID before treating them as alive, which keeps `sps status`, `sps worker dashboard`, and `sps card dashboard` from showing dead workers as active after `sps tick` has already stopped. v0.23.10 closes the remaining state-drift loop without taking ordering authority away from Plane/Trello/Markdown: the PM backend remains the first principle for task order and human drag/drop edits, while SPS only reasserts `Todo` / `Inprogress` for cards still locally owned by a non-idle runtime slot. `MonitorEngine` now includes runtime-owned in-progress cards even when PM has drifted, and `sps card dashboard` fetches all cards in a single backend round-trip while sharing the same `active/merging/stale` worker buckets as `sps status` and `sps worker dashboard`, reducing self-inflicted Plane `429` spikes. v0.23.11 begins landing the state-authority redesign itself: `RuntimeCoordinator` can now build a PM + worktree + session-first runtime projection without persisting it, tick startup persists that projection before recovery, and all read-only views now consume a shared snapshot path instead of mutating `state.json` / `acp-state.json` during display refreshes. v0.23.12 then moves the execution path onto the same model: `Recovery` now restores tasks from `TaskLease + WorktreeEvidence` instead of old slot snapshots, and `ExecutionEngine` / `PostActions` / `MonitorEngine` now treat `lease.phase` and `lease.retryCount` as the authoritative runtime metadata while `activeCards` remains a derived projection for compatibility. The state file writers now also use unique temporary filenames so overlapping writes do not collide on a shared `.tmp` path. Codex has been verified on launch, recovery, direct merge, same-session resume, PTY conflict fallback, spawn-helper self-heal, immediate post-launch run-state inspection, summary-style dashboard rendering, cold-state liveness validation, runtime-owned PM reconciliation, read-only snapshot rendering, and lease-first recovery/execution flow; Claude still depends on host-side `claude auth login` before reaching `ready`.
126
+ PTY transport now extends the same session/run model into the default CLI-backed worker path. When `WORKER_TRANSPORT=pty` or `WORKER_TRANSPORT=acp`, `sps tick` launches work through `sessionId/runId`, persists that state into `runtime/state.json` plus `runtime/acp-state.json`, and lets recovery/status/dashboard inspect the live session/run state. `PostActions` retry/conflict flows and `CloseoutEngine` autofix/conflict flows now resume the same session when possible and automatically rebuild a fresh persistent session when the original one is gone before a retry or merge-conflict follow-up run. v0.23.5 upgrades `node-pty` to a macOS-safe build, auto-restores missing execute permissions on `spawn-helper`, and auto-skips the benign Codex update notice during PTY boot so `ensureSession()` can reach `ready` on fresh launches instead of failing with `posix_spawnp failed`. v0.23.6 further fixes PTY run lifecycle tracking so newly submitted conflict-resolution runs no longer get marked `completed` during the first inspect cycle before the CLI actually leaves the prompt. v0.23.7 hardens `sps worker dashboard` itself by switching PTY/TUI panels from raw pane dumps to structured worker summaries, preventing Codex/Claude full-screen redraw artifacts from corrupting the dashboard layout while also counting PTY-backed active sessions correctly in the summary bar. v0.23.9 now validates persisted PTY sessions by PID before treating them as alive, which keeps `sps status`, `sps worker dashboard`, and `sps card dashboard` from showing dead workers as active after `sps tick` has already stopped. v0.23.10 closes the remaining state-drift loop without taking ordering authority away from Plane/Trello/Markdown: the PM backend remains the first principle for task order and human drag/drop edits, while SPS only reasserts `Todo` / `Inprogress` for cards still locally owned by a non-idle runtime slot. `MonitorEngine` now includes runtime-owned in-progress cards even when PM has drifted, and `sps card dashboard` fetches all cards in a single backend round-trip while sharing the same `active/merging/stale` worker buckets as `sps status` and `sps worker dashboard`, reducing self-inflicted Plane `429` spikes. v0.23.11 begins landing the state-authority redesign itself: `RuntimeCoordinator` can now build a PM + worktree + session-first runtime projection without persisting it, tick startup persists that projection before recovery, and all read-only views now consume a shared snapshot path instead of mutating `state.json` / `acp-state.json` during display refreshes. v0.23.12 then moves the execution path onto the same model: `Recovery` now restores tasks from `TaskLease + WorktreeEvidence` instead of old slot snapshots, and `ExecutionEngine` / `PostActions` / `MonitorEngine` now treat `lease.phase` and `lease.retryCount` as the authoritative runtime metadata while `activeCards` remains a derived projection for compatibility. v0.23.13 extends that cleanup into the remaining control loops: `MonitorEngine` now walks live leases instead of active slots for health, timeout, stale, waiting, and alignment checks; `CloseoutEngine` now releases completed tasks through `RuntimeStore.releaseTaskProjection()`; and `RuntimeStore` becomes the shared mutation surface for monitor/closeout cleanup instead of direct `state.json` rewrites. The state file writers now also use unique temporary filenames so overlapping writes do not collide on a shared `.tmp` path. Codex has been verified on launch, recovery, direct merge, same-session resume, PTY conflict fallback, spawn-helper self-heal, immediate post-launch run-state inspection, summary-style dashboard rendering, cold-state liveness validation, runtime-owned PM reconciliation, read-only snapshot rendering, and lease-first recovery/execution/monitor/closeout flow; Claude still depends on host-side `claude auth login` before reaching `ready`.
127
127
 
128
128
  ### MR_MODE=create (Optional)
129
129
 
@@ -0,0 +1,44 @@
1
+ import type { ProjectContext } from './context.js';
2
+ import { type ActiveCardState, type RuntimeState, type TaskLease, type WorkerSlotState, type WorktreeEvidence } from './state.js';
3
+ import type { ACPState, ACPSessionRecord } from '../models/acp.js';
4
+ export interface TaskRuntimeView {
5
+ seq: string;
6
+ lease: TaskLease | null;
7
+ evidence: WorktreeEvidence | null;
8
+ activeCard: ActiveCardState | null;
9
+ slotName: string | null;
10
+ slot: WorkerSlotState | null;
11
+ session: ACPSessionRecord | null;
12
+ }
13
+ type RuntimePaths = Pick<ProjectContext, 'paths' | 'maxWorkers'>;
14
+ export declare class RuntimeStore {
15
+ private readonly ctx;
16
+ constructor(ctx: RuntimePaths);
17
+ readState(): RuntimeState;
18
+ readACPState(): ACPState;
19
+ read(): {
20
+ state: RuntimeState;
21
+ acpState: ACPState;
22
+ };
23
+ updateState(updatedBy: string, mutator: (state: RuntimeState) => void): RuntimeState;
24
+ updateACPState(updatedBy: string, mutator: (acpState: ACPState) => void): ACPState;
25
+ updateRuntime(updatedBy: string, mutator: (state: RuntimeState, acpState: ACPState) => void): {
26
+ state: RuntimeState;
27
+ acpState: ACPState;
28
+ };
29
+ getTask(seq: string, state?: RuntimeState, acpState?: ACPState): TaskRuntimeView;
30
+ findSlotForTask(seq: string, state: RuntimeState): string | null;
31
+ findAvailableSlot(state: RuntimeState, options?: {
32
+ preferred?: string | null;
33
+ exclude?: Set<string>;
34
+ }): string | null;
35
+ clearWorkerSlot(state: RuntimeState, slotName: string): void;
36
+ releaseTaskProjection(state: RuntimeState, seq: string, options?: {
37
+ dropLease?: boolean;
38
+ phase?: TaskLease['phase'];
39
+ keepWorktree?: boolean;
40
+ pmStateObserved?: TaskLease['pmStateObserved'];
41
+ }): void;
42
+ }
43
+ export {};
44
+ //# sourceMappingURL=runtimeStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtimeStore.d.ts","sourceRoot":"","sources":["../../src/core/runtimeStore.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAEnD,OAAO,EAIL,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACtB,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAEnE,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,SAAS,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAClC,UAAU,EAAE,eAAe,GAAG,IAAI,CAAC;IACnC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,IAAI,EAAE,eAAe,GAAG,IAAI,CAAC;IAC7B,OAAO,EAAE,gBAAgB,GAAG,IAAI,CAAC;CAClC;AAED,KAAK,YAAY,GAAG,IAAI,CAAC,cAAc,EAAE,OAAO,GAAG,YAAY,CAAC,CAAC;AAEjE,qBAAa,YAAY;IACX,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAAH,GAAG,EAAE,YAAY;IAE9C,SAAS,IAAI,YAAY;IAIzB,YAAY,IAAI,QAAQ;IAIxB,IAAI,IAAI;QAAE,KAAK,EAAE,YAAY,CAAC;QAAC,QAAQ,EAAE,QAAQ,CAAA;KAAE;IAOnD,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,KAAK,EAAE,YAAY,KAAK,IAAI,GAAG,YAAY;IAOpF,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,QAAQ,EAAE,QAAQ,KAAK,IAAI,GAAG,QAAQ;IAOlF,aAAa,CACX,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,CAAC,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,QAAQ,KAAK,IAAI,GACzD;QAAE,KAAK,EAAE,YAAY,CAAC;QAAC,QAAQ,EAAE,QAAQ,CAAA;KAAE;IAS9C,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,YAAY,EAAE,QAAQ,CAAC,EAAE,QAAQ,GAAG,eAAe;IAuBhF,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,YAAY,GAAG,MAAM,GAAG,IAAI;IAShE,iBAAiB,CACf,KAAK,EAAE,YAAY,EACnB,OAAO,GAAE;QAAE,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;KAAO,GACjE,MAAM,GAAG,IAAI;IAgBhB,eAAe,CAAC,KAAK,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI;IAI5D,qBAAqB,CACnB,KAAK,EAAE,YAAY,EACnB,GAAG,EAAE,MAAM,EACX,OAAO,GAAE;QACP,SAAS,CAAC,EAAE,OAAO,CAAC;QACpB,KAAK,CAAC,EAAE,SAAS,CAAC,OAAO,CAAC,CAAC;QAC3B,YAAY,CAAC,EAAE,OAAO,CAAC;QACvB,eAAe,CAAC,EAAE,SAAS,CAAC,iBAAiB,CAAC,CAAC;KAC3C,GACL,IAAI;CA2BR"}
@@ -0,0 +1,104 @@
1
+ import { readACPState, writeACPState } from './acpState.js';
2
+ import { createIdleWorkerSlot, readState, writeState, } from './state.js';
3
+ export class RuntimeStore {
4
+ ctx;
5
+ constructor(ctx) {
6
+ this.ctx = ctx;
7
+ }
8
+ readState() {
9
+ return readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
10
+ }
11
+ readACPState() {
12
+ return readACPState(this.ctx.paths.acpStateFile);
13
+ }
14
+ read() {
15
+ return {
16
+ state: this.readState(),
17
+ acpState: this.readACPState(),
18
+ };
19
+ }
20
+ updateState(updatedBy, mutator) {
21
+ const state = this.readState();
22
+ mutator(state);
23
+ writeState(this.ctx.paths.stateFile, state, updatedBy);
24
+ return state;
25
+ }
26
+ updateACPState(updatedBy, mutator) {
27
+ const acpState = this.readACPState();
28
+ mutator(acpState);
29
+ writeACPState(this.ctx.paths.acpStateFile, acpState, updatedBy);
30
+ return acpState;
31
+ }
32
+ updateRuntime(updatedBy, mutator) {
33
+ const state = this.readState();
34
+ const acpState = this.readACPState();
35
+ mutator(state, acpState);
36
+ writeState(this.ctx.paths.stateFile, state, updatedBy);
37
+ writeACPState(this.ctx.paths.acpStateFile, acpState, updatedBy);
38
+ return { state, acpState };
39
+ }
40
+ getTask(seq, state, acpState) {
41
+ const runtimeState = state ?? this.readState();
42
+ const runtimeACP = acpState ?? this.readACPState();
43
+ const key = String(seq);
44
+ const lease = runtimeState.leases[key] || null;
45
+ const slotName = lease?.slot ||
46
+ Object.entries(runtimeState.workers).find(([, worker]) => worker.seq === parseInt(key, 10))?.[0] ||
47
+ null;
48
+ const slot = slotName ? runtimeState.workers[slotName] || null : null;
49
+ const session = slotName ? runtimeACP.sessions[slotName] || null : null;
50
+ return {
51
+ seq: key,
52
+ lease,
53
+ evidence: runtimeState.worktreeEvidence[key] || null,
54
+ activeCard: runtimeState.activeCards[key] || null,
55
+ slotName,
56
+ slot,
57
+ session,
58
+ };
59
+ }
60
+ findSlotForTask(seq, state) {
61
+ const key = String(seq);
62
+ return (state.leases[key]?.slot ||
63
+ Object.entries(state.workers).find(([, worker]) => worker.seq === parseInt(key, 10))?.[0] ||
64
+ null);
65
+ }
66
+ findAvailableSlot(state, options = {}) {
67
+ const exclude = options.exclude ?? new Set();
68
+ if (options.preferred) {
69
+ const preferred = state.workers[options.preferred];
70
+ if (preferred && preferred.status === 'idle' && !exclude.has(options.preferred)) {
71
+ return options.preferred;
72
+ }
73
+ }
74
+ return (Object.entries(state.workers).find(([slotName, worker]) => worker.status === 'idle' && !exclude.has(slotName))?.[0] || null);
75
+ }
76
+ clearWorkerSlot(state, slotName) {
77
+ state.workers[slotName] = createIdleWorkerSlot();
78
+ }
79
+ releaseTaskProjection(state, seq, options = {}) {
80
+ const slotName = this.findSlotForTask(seq, state);
81
+ if (slotName) {
82
+ this.clearWorkerSlot(state, slotName);
83
+ }
84
+ delete state.activeCards[seq];
85
+ if (options.dropLease) {
86
+ delete state.leases[seq];
87
+ return;
88
+ }
89
+ const lease = state.leases[seq];
90
+ if (!lease)
91
+ return;
92
+ lease.slot = null;
93
+ lease.sessionId = null;
94
+ lease.runId = null;
95
+ lease.phase = options.phase ?? 'suspended';
96
+ lease.pmStateObserved = options.pmStateObserved ?? lease.pmStateObserved;
97
+ if (!options.keepWorktree) {
98
+ lease.worktree = null;
99
+ lease.branch = null;
100
+ }
101
+ lease.lastTransitionAt = new Date().toISOString();
102
+ }
103
+ }
104
+ //# sourceMappingURL=runtimeStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"runtimeStore.js","sourceRoot":"","sources":["../../src/core/runtimeStore.ts"],"names":[],"mappings":"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"}
@@ -27,6 +27,7 @@ export declare class CloseoutEngine {
27
27
  private notifier?;
28
28
  private agentRuntime;
29
29
  private log;
30
+ private runtimeStore;
30
31
  constructor(ctx: ProjectContext, taskBackend: TaskBackend, repoBackend: RepoBackend, workerProvider: WorkerProvider, notifier?: Notifier | undefined, agentRuntime?: AgentRuntime | null);
31
32
  tick(): Promise<CommandResult>;
32
33
  private processQaCard;
@@ -1 +1 @@
1
- {"version":3,"file":"CloseoutEngine.d.ts","sourceRoot":"","sources":["../../src/engines/CloseoutEngine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AACtE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAElE,OAAO,KAAK,EAAE,aAAa,EAAyC,MAAM,oBAAoB,CAAC;AAK/F;;;;;;;;;;;;;GAaG;AACH,qBAAa,cAAc;IAIvB,OAAO,CAAC,GAAG;IACX,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,cAAc;IACtB,OAAO,CAAC,QAAQ,CAAC;IACjB,OAAO,CAAC,YAAY;IARtB,OAAO,CAAC,GAAG,CAAS;gBAGV,GAAG,EAAE,cAAc,EACnB,WAAW,EAAE,WAAW,EACxB,WAAW,EAAE,WAAW,EACxB,cAAc,EAAE,cAAc,EAC9B,QAAQ,CAAC,EAAE,QAAQ,YAAA,EACnB,YAAY,GAAE,YAAY,GAAG,IAAW;IAK5C,IAAI,IAAI,OAAO,CAAC,aAAa,CAAC;YAoEtB,aAAa;YAwEb,aAAa;YAqHb,eAAe;YA8Hf,YAAY;IAyC1B;;;;;;;;;;OAUG;YACW,cAAc;IAuI5B;;;;;OAKG;YACW,eAAe;IAoI7B;;;;;OAKG;YACW,gBAAgB;IA4C9B,OAAO,CAAC,eAAe;YAST,YAAY;YAMZ,YAAY;YASZ,WAAW;YASX,UAAU;YAYV,eAAe;IA+C7B,OAAO,CAAC,QAAQ;CAcjB"}
1
+ {"version":3,"file":"CloseoutEngine.d.ts","sourceRoot":"","sources":["../../src/engines/CloseoutEngine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,8BAA8B,CAAC;AAChE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iCAAiC,CAAC;AACtE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,2BAA2B,CAAC;AAC1D,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,+BAA+B,CAAC;AAElE,OAAO,KAAK,EAAE,aAAa,EAAyC,MAAM,oBAAoB,CAAC;AAK/F;;;;;;;;;;;;;GAaG;AACH,qBAAa,cAAc;IAKvB,OAAO,CAAC,GAAG;IACX,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,cAAc;IACtB,OAAO,CAAC,QAAQ,CAAC;IACjB,OAAO,CAAC,YAAY;IATtB,OAAO,CAAC,GAAG,CAAS;IACpB,OAAO,CAAC,YAAY,CAAe;gBAGzB,GAAG,EAAE,cAAc,EACnB,WAAW,EAAE,WAAW,EACxB,WAAW,EAAE,WAAW,EACxB,cAAc,EAAE,cAAc,EAC9B,QAAQ,CAAC,EAAE,QAAQ,YAAA,EACnB,YAAY,GAAE,YAAY,GAAG,IAAW;IAM5C,IAAI,IAAI,OAAO,CAAC,aAAa,CAAC;YAoEtB,aAAa;YAwEb,aAAa;YAqHb,eAAe;YAkIf,YAAY;IAyC1B;;;;;;;;;;OAUG;YACW,cAAc;IAsH5B;;;;;OAKG;YACW,eAAe;IA0I7B;;;;;OAKG;YACW,gBAAgB;IA4C9B,OAAO,CAAC,eAAe;YAST,YAAY;YAMZ,YAAY;YASZ,WAAW;YASX,UAAU;YAYV,eAAe;IAyD7B,OAAO,CAAC,QAAQ;CAcjB"}
@@ -1,4 +1,4 @@
1
- import { readState, writeState } from '../core/state.js';
1
+ import { RuntimeStore } from '../core/runtimeStore.js';
2
2
  import { resolveWorktreePath } from '../core/paths.js';
3
3
  import { Logger } from '../core/logger.js';
4
4
  /**
@@ -23,6 +23,7 @@ export class CloseoutEngine {
23
23
  notifier;
24
24
  agentRuntime;
25
25
  log;
26
+ runtimeStore;
26
27
  constructor(ctx, taskBackend, repoBackend, workerProvider, notifier, agentRuntime = null) {
27
28
  this.ctx = ctx;
28
29
  this.taskBackend = taskBackend;
@@ -31,6 +32,7 @@ export class CloseoutEngine {
31
32
  this.notifier = notifier;
32
33
  this.agentRuntime = agentRuntime;
33
34
  this.log = new Logger('qa', ctx.projectName, ctx.paths.logsDir);
35
+ this.runtimeStore = new RuntimeStore(ctx);
34
36
  }
35
37
  async tick() {
36
38
  const actions = [];
@@ -274,37 +276,41 @@ export class CloseoutEngine {
274
276
  }
275
277
  const autofixAttempts = typeof meta.autofixAttempts === 'number' ? meta.autofixAttempts : 0;
276
278
  if (autofixAttempts < maxAttempts) {
277
- // Try self-repair: find the worker session for this card
278
- const state = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
279
- const slotEntry = Object.entries(state.workers).find(([, w]) => w.seq === parseInt(seq, 10) && w.tmuxSession);
280
- if (slotEntry) {
281
- const [slotName, slotState] = slotEntry;
282
- const session = slotState.tmuxSession;
283
- const isPrintMode = slotState.mode === 'print';
284
- const isAcpMode = slotState.transport === 'acp' ||
279
+ // Try self-repair: prefer lease/worktree evidence, then reuse or rebuild a worker slot.
280
+ const state = this.runtimeStore.readState();
281
+ const runtime = this.runtimeStore.getTask(seq, state);
282
+ const slotName = runtime.slotName ||
283
+ this.runtimeStore.findAvailableSlot(state);
284
+ const slotState = slotName ? state.workers[slotName] || null : null;
285
+ const session = slotState?.tmuxSession || null;
286
+ const worktree = runtime.lease?.worktree || runtime.evidence?.worktree || slotState?.worktree || '';
287
+ const isPrintMode = slotState?.mode === 'print';
288
+ const isAcpMode = !!slotState &&
289
+ (slotState.transport === 'acp' ||
285
290
  slotState.transport === 'pty' ||
286
291
  slotState.mode === 'acp' ||
287
- slotState.mode === 'pty';
292
+ slotState.mode === 'pty');
293
+ if (slotName && (session || (isAcpMode && this.agentRuntime && worktree))) {
288
294
  try {
289
295
  const fixPrompt = `CI pipeline has failed. Please review the CI logs, fix the issues, commit, and push. This is autofix attempt ${autofixAttempts + 1} of ${maxAttempts}.`;
290
296
  if (isAcpMode && this.agentRuntime) {
291
- await this.resumeAcpWorker(slotName, seq, slotState.worktree || '', branchName, fixPrompt, 'active', 'closeout-autofix-resume');
297
+ await this.resumeAcpWorker(slotName, seq, worktree, branchName, fixPrompt, 'active', 'closeout-autofix-resume');
292
298
  }
293
299
  else if (isPrintMode) {
294
300
  // Print mode: spawn new process with --resume (process already exited)
295
- const resumeResult = await this.workerProvider.sendFix(session, fixPrompt, slotState.sessionId || undefined);
301
+ const resumeResult = await this.workerProvider.sendFix(session, fixPrompt, slotState?.sessionId || undefined);
296
302
  // Update state with new process info
297
303
  if (resumeResult && typeof resumeResult === 'object' && 'pid' in resumeResult) {
298
- const freshState = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
299
- if (freshState.workers[slotName]) {
300
- freshState.workers[slotName].pid = resumeResult.pid;
301
- freshState.workers[slotName].outputFile = resumeResult.outputFile;
302
- if (resumeResult.sessionId) {
303
- freshState.workers[slotName].sessionId = resumeResult.sessionId;
304
+ this.runtimeStore.updateState('closeout-autofix-resume', (freshState) => {
305
+ if (freshState.workers[slotName]) {
306
+ freshState.workers[slotName].pid = resumeResult.pid;
307
+ freshState.workers[slotName].outputFile = resumeResult.outputFile;
308
+ if (resumeResult.sessionId) {
309
+ freshState.workers[slotName].sessionId = resumeResult.sessionId;
310
+ }
311
+ freshState.workers[slotName].exitCode = null;
304
312
  }
305
- freshState.workers[slotName].exitCode = null;
306
- writeState(this.ctx.paths.stateFile, freshState, 'closeout-autofix-resume');
307
- }
313
+ });
308
314
  }
309
315
  }
310
316
  else {
@@ -427,36 +433,17 @@ export class CloseoutEngine {
427
433
  errors.push(`release-claim: ${msg}`);
428
434
  }
429
435
  // Step 3: Release worker slot in state.json
430
- const state = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
431
- const slotEntry = Object.entries(state.workers).find(([, w]) => w.seq === parseInt(seq, 10));
436
+ const state = this.runtimeStore.readState();
437
+ const runtime = this.runtimeStore.getTask(seq, state);
438
+ const slotEntry = runtime.slotName && runtime.slot ? [runtime.slotName, runtime.slot] : null;
432
439
  let sessionName = null;
433
440
  if (slotEntry) {
434
441
  const [slotName, slotState] = slotEntry;
435
442
  sessionName = slotState.tmuxSession;
436
443
  try {
437
- state.workers[slotName] = {
438
- status: 'idle',
439
- seq: null,
440
- branch: null,
441
- worktree: null,
442
- tmuxSession: null,
443
- claimedAt: null,
444
- lastHeartbeat: null,
445
- mode: null,
446
- transport: null,
447
- agent: null,
448
- sessionId: null,
449
- runId: null,
450
- sessionState: null,
451
- remoteStatus: null,
452
- lastEventAt: null,
453
- pid: null,
454
- outputFile: null,
455
- exitCode: null,
456
- };
457
- delete state.activeCards[seq];
458
- delete state.leases[seq];
459
- writeState(this.ctx.paths.stateFile, state, 'closeout-release');
444
+ this.runtimeStore.updateState('closeout-release', (draft) => {
445
+ this.runtimeStore.releaseTaskProjection(draft, seq, { dropLease: true });
446
+ });
460
447
  this.log.ok(`seq ${seq}: Worker slot ${slotName} released`);
461
448
  }
462
449
  catch (err) {
@@ -468,11 +455,11 @@ export class CloseoutEngine {
468
455
  else {
469
456
  // No active slot found — already released (idempotency)
470
457
  // Still clean up activeCards entry if present
471
- if (state.activeCards[seq]) {
472
- delete state.activeCards[seq];
473
- delete state.leases[seq];
458
+ if (state.activeCards[seq] || state.leases[seq]) {
474
459
  try {
475
- writeState(this.ctx.paths.stateFile, state, 'closeout-release');
460
+ this.runtimeStore.updateState('closeout-release', (draft) => {
461
+ this.runtimeStore.releaseTaskProjection(draft, seq, { dropLease: true });
462
+ });
476
463
  }
477
464
  catch {
478
465
  // non-fatal
@@ -494,15 +481,18 @@ export class CloseoutEngine {
494
481
  }
495
482
  // Step 5: Mark worktree for cleanup (actual removal runs at end of tick)
496
483
  try {
497
- const freshState = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
484
+ const freshState = this.runtimeStore.readState();
498
485
  const branchName = this.buildBranchName(card);
499
- const worktreePath = resolveWorktreePath(this.ctx.projectName, seq, this.ctx.config.WORKTREE_DIR);
486
+ const worktreePath = runtime.lease?.worktree ||
487
+ runtime.evidence?.worktree ||
488
+ resolveWorktreePath(this.ctx.projectName, seq, this.ctx.config.WORKTREE_DIR);
500
489
  const cleanup = freshState.worktreeCleanup ?? [];
501
490
  const alreadyMarked = cleanup.some((e) => e.branch === branchName);
502
491
  if (!alreadyMarked) {
503
492
  cleanup.push({ branch: branchName, worktreePath, markedAt: new Date().toISOString() });
504
- freshState.worktreeCleanup = cleanup;
505
- writeState(this.ctx.paths.stateFile, freshState, 'closeout-worktree-mark');
493
+ this.runtimeStore.updateState('closeout-worktree-mark', (draft) => {
494
+ draft.worktreeCleanup = cleanup;
495
+ });
506
496
  }
507
497
  this.log.ok(`seq ${seq}: Worktree marked for cleanup`);
508
498
  }
@@ -540,11 +530,11 @@ export class CloseoutEngine {
540
530
  async resolveConflict(card, actions) {
541
531
  const seq = card.seq;
542
532
  const branchName = this.buildBranchName(card);
543
- const state = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
533
+ const state = this.runtimeStore.readState();
534
+ const runtime = this.runtimeStore.getTask(seq, state);
544
535
  const baseBranch = this.ctx.mergeBranch;
545
536
  // Find worktree for this card
546
- const slotEntry = Object.entries(state.workers).find(([, w]) => w.seq === parseInt(seq, 10));
547
- const worktree = slotEntry?.[1]?.worktree;
537
+ const worktree = runtime.lease?.worktree || runtime.evidence?.worktree || runtime.slot?.worktree;
548
538
  if (!worktree) {
549
539
  this.log.warn(`seq ${seq}: No worktree found for conflict resolution`);
550
540
  return false;
@@ -576,19 +566,17 @@ export class CloseoutEngine {
576
566
  this.log.warn(`seq ${seq}: L1 rebase threw: ${msg}`);
577
567
  }
578
568
  // ── L2: Send to original worker ──────────────────────────────
579
- if (!slotEntry)
580
- return false;
581
- const [slotName, slotState] = slotEntry;
582
- const session = slotState.tmuxSession;
583
- if (!session) {
584
- this.log.warn(`seq ${seq}: No worker session for L2 conflict resolution`);
569
+ const slotName = runtime.slotName || this.runtimeStore.findAvailableSlot(state);
570
+ if (!slotName)
585
571
  return false;
586
- }
587
- const isPrintMode = slotState.mode === 'print';
588
- const isAcpMode = slotState.transport === 'acp' ||
589
- slotState.transport === 'pty' ||
590
- slotState.mode === 'acp' ||
591
- slotState.mode === 'pty';
572
+ const slotState = state.workers[slotName];
573
+ const session = slotState?.tmuxSession || null;
574
+ const isPrintMode = slotState?.mode === 'print';
575
+ const isAcpMode = !!slotState &&
576
+ (slotState.transport === 'acp' ||
577
+ slotState.transport === 'pty' ||
578
+ slotState.mode === 'acp' ||
579
+ slotState.mode === 'pty');
592
580
  try {
593
581
  if (isAcpMode && this.agentRuntime) {
594
582
  await this.resumeAcpWorker(slotName, seq, worktree, branchName, [
@@ -598,24 +586,32 @@ export class CloseoutEngine {
598
586
  ].join('\n'), 'resolving', 'closeout-conflict-resume');
599
587
  }
600
588
  else if (isPrintMode) {
589
+ if (!session) {
590
+ this.log.warn(`seq ${seq}: No worker session for L2 conflict resolution`);
591
+ return false;
592
+ }
601
593
  // Print mode: spawn new process with --resume
602
594
  this.log.info(`seq ${seq}: L2 — spawning conflict resolution via --resume`);
603
- const resumeResult = await this.workerProvider.resolveConflict(session, worktree, branchName, slotState.sessionId || undefined);
595
+ const resumeResult = await this.workerProvider.resolveConflict(session, worktree, branchName, slotState?.sessionId || undefined);
604
596
  // Update state with new process info
605
597
  if (resumeResult && typeof resumeResult === 'object' && 'pid' in resumeResult) {
606
- const freshState = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
607
- if (freshState.workers[slotName]) {
608
- freshState.workers[slotName].pid = resumeResult.pid;
609
- freshState.workers[slotName].outputFile = resumeResult.outputFile;
610
- if (resumeResult.sessionId) {
611
- freshState.workers[slotName].sessionId = resumeResult.sessionId;
598
+ this.runtimeStore.updateState('closeout-conflict-resume', (freshState) => {
599
+ if (freshState.workers[slotName]) {
600
+ freshState.workers[slotName].pid = resumeResult.pid;
601
+ freshState.workers[slotName].outputFile = resumeResult.outputFile;
602
+ if (resumeResult.sessionId) {
603
+ freshState.workers[slotName].sessionId = resumeResult.sessionId;
604
+ }
605
+ freshState.workers[slotName].exitCode = null;
612
606
  }
613
- freshState.workers[slotName].exitCode = null;
614
- writeState(this.ctx.paths.stateFile, freshState, 'closeout-conflict-resume');
615
- }
607
+ });
616
608
  }
617
609
  }
618
610
  else {
611
+ if (!session) {
612
+ this.log.warn(`seq ${seq}: No live worker session for interactive L2 conflict resolution`);
613
+ return false;
614
+ }
619
615
  // Interactive mode: send to live tmux session
620
616
  const inspection = await this.workerProvider.inspect(session);
621
617
  if (!inspection.alive) {
@@ -649,7 +645,7 @@ export class CloseoutEngine {
649
645
  * Each entry is processed independently — one failure does not block others.
650
646
  */
651
647
  async cleanupWorktrees(actions) {
652
- const state = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
648
+ const state = this.runtimeStore.readState();
653
649
  const queue = state.worktreeCleanup ?? [];
654
650
  if (queue.length === 0)
655
651
  return;
@@ -680,9 +676,9 @@ export class CloseoutEngine {
680
676
  }
681
677
  }
682
678
  // Update state with remaining entries
683
- const freshState = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
684
- freshState.worktreeCleanup = remaining;
685
- writeState(this.ctx.paths.stateFile, freshState, 'closeout-worktree-cleanup');
679
+ this.runtimeStore.updateState('closeout-worktree-cleanup', (freshState) => {
680
+ freshState.worktreeCleanup = remaining;
681
+ });
686
682
  }
687
683
  // ─── Helpers ───────────────────────────────────────────────────
688
684
  buildBranchName(card) {
@@ -740,27 +736,37 @@ export class CloseoutEngine {
740
736
  await this.agentRuntime.ensureSession(slotName, undefined, worktree);
741
737
  session = await this.agentRuntime.startRun(slotName, prompt, undefined, worktree);
742
738
  }
743
- const state = readState(this.ctx.paths.stateFile, this.ctx.maxWorkers);
744
- const slot = state.workers[slotName];
745
- if (slot) {
746
- slot.status = slotStatus;
747
- slot.mode = this.ctx.config.WORKER_TRANSPORT === 'pty' ? 'pty' : 'acp';
748
- slot.transport = this.ctx.config.WORKER_TRANSPORT === 'pty' ? 'pty' : 'acp';
749
- slot.agent = session.tool;
750
- slot.tmuxSession = session.sessionName;
751
- slot.sessionId = session.sessionId;
752
- slot.runId = session.currentRun?.runId || null;
753
- slot.sessionState = session.sessionState;
754
- slot.remoteStatus = session.currentRun?.status || null;
755
- slot.lastEventAt = session.lastSeenAt;
756
- slot.lastHeartbeat = new Date().toISOString();
757
- slot.branch = slot.branch || branchName;
758
- slot.worktree = slot.worktree || worktree;
759
- slot.pid = null;
760
- slot.outputFile = null;
761
- slot.exitCode = null;
762
- }
763
- writeState(this.ctx.paths.stateFile, state, updatedBy);
739
+ this.runtimeStore.updateState(updatedBy, (state) => {
740
+ const slot = state.workers[slotName];
741
+ if (slot) {
742
+ slot.status = slotStatus;
743
+ slot.mode = this.ctx.config.WORKER_TRANSPORT === 'pty' ? 'pty' : 'acp';
744
+ slot.transport = this.ctx.config.WORKER_TRANSPORT === 'pty' ? 'pty' : 'acp';
745
+ slot.agent = session.tool;
746
+ slot.tmuxSession = session.sessionName;
747
+ slot.sessionId = session.sessionId;
748
+ slot.runId = session.currentRun?.runId || null;
749
+ slot.sessionState = session.sessionState;
750
+ slot.remoteStatus = session.currentRun?.status || null;
751
+ slot.lastEventAt = session.lastSeenAt;
752
+ slot.lastHeartbeat = new Date().toISOString();
753
+ slot.branch = slot.branch || branchName;
754
+ slot.worktree = slot.worktree || worktree;
755
+ slot.seq = parseInt(seq, 10);
756
+ slot.pid = null;
757
+ slot.outputFile = null;
758
+ slot.exitCode = null;
759
+ }
760
+ if (state.leases[seq]) {
761
+ state.leases[seq].slot = slotName;
762
+ state.leases[seq].branch = branchName;
763
+ state.leases[seq].worktree = worktree;
764
+ state.leases[seq].sessionId = session.sessionId;
765
+ state.leases[seq].runId = session.currentRun?.runId || null;
766
+ state.leases[seq].phase = slotStatus === 'resolving' ? 'resolving_conflict' : 'coding';
767
+ state.leases[seq].lastTransitionAt = new Date().toISOString();
768
+ }
769
+ });
764
770
  }
765
771
  logEvent(action, seq, result, meta) {
766
772
  this.log.event({