pi-crew 0.9.68 → 0.10.2
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/CHANGELOG.md +222 -0
- package/NOTICE.md +21 -0
- package/README.md +44 -2
- package/agents/analyst.md +1 -1
- package/agents/cold-verifier.md +3 -1
- package/agents/critic.md +1 -1
- package/agents/executor.md +1 -1
- package/agents/explorer.md +1 -1
- package/agents/planner.md +1 -1
- package/agents/reviewer.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/test-engineer.md +1 -1
- package/agents/verifier.md +1 -1
- package/agents/writer.md +1 -1
- package/dist/index.mjs +68113 -60774
- package/docs/README.md +2 -0
- package/docs/actions-reference.md +31 -0
- package/docs/commands-reference.md +17 -6
- package/docs/resource-formats.md +13 -0
- package/package.json +4 -2
- package/schema.json +503 -91
- package/scripts/resource-sampler.mjs +36 -2
- package/skills/requirements-to-task-packet/SKILL.md +26 -0
- package/skills/widget-rendering/SKILL.md +7 -7
- package/src/agents/agent-config.ts +2 -1
- package/src/agents/discover-agents.ts +23 -14
- package/src/config/config-merge.ts +183 -0
- package/src/config/config-validation.ts +687 -0
- package/src/config/config.ts +22 -864
- package/src/config/defaults.ts +43 -2
- package/src/config/drift-detector.ts +1 -1
- package/src/config/env-vars.ts +691 -0
- package/src/config/role-tools.ts +11 -9
- package/src/config/sanitize-project-config.ts +172 -0
- package/src/config/types.ts +49 -1
- package/src/extension/async-notifier.ts +25 -2
- package/src/extension/crew-cleanup.ts +13 -0
- package/src/extension/crew-vibes/config.ts +2 -1
- package/src/extension/crew-vibes/footer.ts +19 -0
- package/src/extension/crew-vibes/index.ts +11 -1
- package/src/extension/plan-orchestrate.ts +132 -0
- package/src/extension/register.ts +8 -0
- package/src/extension/registration/command-registration.ts +1 -0
- package/src/extension/registration/commands/dashboard.ts +158 -0
- package/src/extension/registration/commands/index.ts +35 -0
- package/src/extension/registration/commands/manage.ts +303 -0
- package/src/extension/registration/commands/run.ts +228 -0
- package/src/extension/registration/commands/shared.ts +639 -0
- package/src/extension/registration/commands/status.ts +60 -0
- package/src/extension/registration/commands.ts +13 -1224
- package/src/extension/registration/foreground-run-controller.ts +10 -2
- package/src/extension/registration/lifecycle-handlers.ts +178 -17
- package/src/extension/registration/runtime-cleanup.ts +23 -5
- package/src/extension/registration/subagent-tools.ts +218 -9
- package/src/extension/registration/team-tool.ts +5 -1
- package/src/extension/registration/ui.ts +5 -4
- package/src/extension/rpc-hmac.ts +5 -3
- package/src/extension/team-tool/api/heartbeat.ts +47 -10
- package/src/extension/team-tool/api/plan-approval.ts +9 -0
- package/src/extension/team-tool/api/task-claims.ts +109 -40
- package/src/extension/team-tool/cancel.ts +84 -50
- package/src/extension/team-tool/dispatch/index.ts +1 -0
- package/src/extension/team-tool/dispatch/run.ts +4 -1
- package/src/extension/team-tool/doctor.ts +103 -1
- package/src/extension/team-tool/orchestrate.ts +66 -1
- package/src/extension/team-tool/plans.ts +192 -0
- package/src/extension/team-tool/respond.ts +197 -65
- package/src/extension/team-tool/run-deadline.ts +35 -3
- package/src/extension/team-tool/run-intent.ts +63 -0
- package/src/extension/team-tool/run.ts +74 -20
- package/src/extension/team-tool/status.ts +84 -26
- package/src/extension/team-tool.ts +11 -2
- package/src/hooks/registry.ts +1 -6
- package/src/i18n.ts +9 -0
- package/src/prompt/prompt-runtime.ts +521 -2
- package/src/prompt/worker-events-channel.ts +173 -0
- package/src/runtime/README.md +8 -8
- package/src/runtime/async-runner.ts +7 -3
- package/src/runtime/background-runner.ts +42 -14
- package/src/runtime/broker/broker-issuer.ts +9 -2
- package/src/runtime/broker/crew-broker-tokens.ts +43 -6
- package/src/runtime/broker/crew-broker.ts +838 -10
- package/src/runtime/broker/wait-status-cache.ts +157 -0
- package/src/runtime/budget-enforcement.ts +281 -0
- package/src/runtime/child-pi/child-pi-constants.ts +8 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
- package/src/runtime/child-pi/child-pi-streams.ts +21 -1
- package/src/runtime/child-pi/child-pi-timers.ts +324 -0
- package/src/runtime/child-pi/child-pi.ts +97 -201
- package/src/runtime/child-pi/mock-fixtures.ts +16 -2
- package/src/runtime/crew-agent-records.ts +259 -14
- package/src/runtime/delegate-spawn.ts +148 -0
- package/src/runtime/detached-run-results.ts +90 -0
- package/src/runtime/deterministic-ast.ts +2 -1
- package/src/runtime/dispatch-batch.ts +945 -0
- package/src/runtime/finalize-run.ts +557 -0
- package/src/runtime/goal-workflow/adaptive-plan.ts +116 -15
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +8 -3
- package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
- package/src/runtime/group-join.ts +11 -125
- package/src/runtime/live-session/live-session-runtime.ts +26 -1
- package/src/runtime/merge-gate.ts +32 -10
- package/src/runtime/merge-loop.ts +130 -0
- package/src/runtime/model/model-budget-summary.ts +53 -0
- package/src/runtime/model/model-fallback.ts +36 -2
- package/src/runtime/model/pi-args.ts +10 -0
- package/src/runtime/model/provider-extensions.ts +10 -0
- package/src/runtime/orphan-worker-registry.ts +1 -1
- package/src/runtime/output/output-validator.ts +45 -0
- package/src/runtime/parent-guard.ts +3 -1
- package/src/runtime/peer-dep.ts +2 -1
- package/src/runtime/per-write-validator.ts +0 -5
- package/src/runtime/pi-spawn.ts +61 -15
- package/src/runtime/plan-approval.ts +125 -0
- package/src/runtime/plan-replan.ts +151 -0
- package/src/runtime/process-status.ts +16 -1
- package/src/runtime/recovery/checkpoint.ts +0 -18
- package/src/runtime/recovery/crash-recovery.ts +111 -46
- package/src/runtime/run-tracker.ts +77 -10
- package/src/runtime/scheduler-context.ts +98 -0
- package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
- package/src/runtime/scheduling/global-worker-cap.ts +2 -1
- package/src/runtime/scheduling/nested-slots.ts +70 -0
- package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
- package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
- package/src/runtime/settings-store.ts +219 -0
- package/src/runtime/spawn-policy.ts +217 -0
- package/src/runtime/stale-reconciler.ts +87 -6
- package/src/runtime/subagent-manager.ts +25 -1
- package/src/runtime/task-output-context.ts +230 -9
- package/src/runtime/task-packet.ts +23 -1
- package/src/runtime/task-runner/child-executor.ts +106 -7
- package/src/runtime/task-runner/post-execution.ts +125 -1
- package/src/runtime/task-runner/pre-execution.ts +39 -1
- package/src/runtime/task-runner/prompt-builder.ts +51 -1
- package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
- package/src/runtime/task-runner/spec-evidence.ts +403 -0
- package/src/runtime/task-runner/state-helpers.ts +26 -24
- package/src/runtime/task-runner.ts +11 -0
- package/src/runtime/team-runner.ts +132 -1673
- package/src/runtime/verification/spec-sandbox.ts +255 -0
- package/src/runtime/verification/verification-gates.ts +3 -2
- package/src/runtime/verification/verification-worktree.ts +2 -1
- package/src/runtime/workflow-phase-advance.ts +100 -0
- package/src/runtime/workspace-tree.ts +9 -0
- package/src/schema/config-schema.ts +66 -26
- package/src/schema/sensitive-config-paths.ts +64 -0
- package/src/schema/team-tool-schema.ts +13 -3
- package/src/state/README.md +4 -10
- package/src/state/atomic-write.ts +20 -3
- package/src/state/contracts.ts +38 -0
- package/src/state/coordination/mailbox.ts +12 -2
- package/src/state/event-log/cursor.ts +223 -0
- package/src/state/event-log/event-log-rotation.ts +12 -4
- package/src/state/event-log/event-log.ts +152 -369
- package/src/state/event-log/sequence-cache.ts +373 -0
- package/src/state/event-log/worker-atomic-writer.ts +2 -1
- package/src/state/stores/active-run-registry.ts +3 -2
- package/src/state/stores/manifest-io.ts +237 -0
- package/src/state/stores/ownership-map.ts +162 -0
- package/src/state/stores/plan-store.ts +241 -0
- package/src/state/stores/run-cache.ts +0 -90
- package/src/state/stores/spec-store.ts +189 -0
- package/src/state/stores/state-store.ts +139 -232
- package/src/state/types.ts +199 -0
- package/src/ui/dashboard-panes/plan-pane.ts +136 -0
- package/src/ui/dashboard-panes/progress-pane.ts +6 -0
- package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
- package/src/ui/dock-footer.ts +49 -0
- package/src/ui/heartbeat-aggregator.ts +9 -1
- package/src/ui/inline-panel/agent-pane.ts +375 -0
- package/src/ui/inline-panel/agent-transcript.ts +338 -0
- package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
- package/src/ui/inline-panel/crew-editor.ts +192 -0
- package/src/ui/inline-panel/index.ts +290 -0
- package/src/ui/inline-panel/panel-rows.ts +37 -0
- package/src/ui/inline-panel/panel-selection.ts +157 -0
- package/src/ui/inline-panel/panel-store.ts +111 -0
- package/src/ui/inline-panel/view-session-store.ts +36 -0
- package/src/ui/keybinding-map.ts +54 -13
- package/src/ui/pi-ui-compat.ts +9 -0
- package/src/ui/powerbar-publisher.ts +52 -1
- package/src/ui/run-dashboard.ts +31 -5
- package/src/ui/run-snapshot-cache.ts +57 -30
- package/src/ui/snapshot-types.ts +6 -1
- package/src/ui/widget/index.ts +176 -22
- package/src/ui/widget/task-list.ts +198 -0
- package/src/ui/widget/widget-formatters.ts +240 -4
- package/src/ui/widget/widget-renderer.ts +243 -38
- package/src/ui/widget/widget-types.ts +11 -0
- package/src/utils/child-process-shield.ts +106 -0
- package/src/utils/file-coalescer.ts +0 -4
- package/src/utils/fs-errno.ts +66 -0
- package/src/utils/fs-watch.ts +1 -1
- package/src/utils/internal-error.ts +3 -1
- package/src/utils/paths.ts +11 -3
- package/src/utils/redaction.ts +7 -0
- package/src/utils/safe-abort.ts +45 -0
- package/src/utils/task-name-generator.ts +1 -8
- package/src/workflows/discover-workflows.ts +20 -2
- package/src/workflows/validate-workflow.ts +7 -1
- package/src/workflows/workflow-config.ts +17 -0
- package/src/workflows/workflow-serializer.ts +3 -0
- package/src/worktree/worktree-manager.ts +22 -0
- package/workflows/default.workflow.md +36 -26
- package/workflows/strict-fast-fix.workflow.md +26 -0
- package/src/agents/agent-search.ts +0 -98
- package/src/benchmark/benchmark-runner.ts +0 -313
- package/src/benchmark/feedback-loop.ts +0 -73
- package/src/config/resilient-parser.ts +0 -117
- package/src/extension/crew-vibes/cat-frames.ts +0 -18
- package/src/extension/result-watcher.ts +0 -139
- package/src/observability/exporters/prometheus-exporter.ts +0 -54
- package/src/observability/metric-retention.ts +0 -64
- package/src/runtime/compaction/compaction-summary.ts +0 -278
- package/src/runtime/errors/crew-errors.ts +0 -162
- package/src/runtime/live-session/intercom-bridge.ts +0 -187
- package/src/runtime/loop-gates.ts +0 -128
- package/src/runtime/metric-parser.ts +0 -36
- package/src/runtime/output/stream-preview.ts +0 -184
- package/src/runtime/output/tool-progress.ts +0 -278
- package/src/runtime/phase-tracker.ts +0 -385
- package/src/runtime/pipeline-runner.ts +0 -523
- package/src/runtime/process/process-lifecycle.ts +0 -491
- package/src/runtime/recovery/retry-runner.ts +0 -330
- package/src/runtime/run-drift.ts +0 -219
- package/src/runtime/task-quality.ts +0 -199
- package/src/runtime/task-runner/run-projection.ts +0 -128
- package/src/runtime/verification/post-checks.ts +0 -142
- package/src/state/coordination/schedule.ts +0 -166
- package/src/state/event-log/jsonl-writer.ts +0 -115
- package/src/state/hook-instinct-bridge.ts +0 -94
- package/src/state/hook-integrations.ts +0 -51
- package/src/state/session-state-map.ts +0 -51
- package/src/state/stores/blob-store.ts +0 -308
- package/src/state/stores/instinct-store.ts +0 -275
- package/src/state/stores/observation-store.ts +0 -176
- package/src/state/tiered-eval.ts +0 -480
- package/src/state/types-eval.ts +0 -58
- package/src/tools/safe-bash-extension.ts +0 -54
- package/src/tools/safe-bash.ts +0 -505
- package/src/ui/agent-management-overlay.ts +0 -160
- package/src/ui/crew-footer.ts +0 -102
- package/src/ui/crew-select-list.ts +0 -114
- package/src/ui/dashboard-panes/capability-pane.ts +0 -77
- package/src/ui/transcript-entries.ts +0 -256
- package/src/utils/conflict-detect.ts +0 -721
- package/src/utils/fingerprint.ts +0 -180
- package/src/utils/gh-protocol.ts +0 -556
- package/src/utils/project-detector.ts +0 -160
- package/src/utils/sse-parser.ts +0 -131
- package/src/workflows/cost-estimator.ts +0 -34
- package/src/workflows/intermediate-store.ts +0 -166
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spec-store.ts — SpecRecord store + provenance mint (ADR-6 §1/§4, WP-6 step 2;
|
|
3
|
+
* round-1 security fix: provenance v2).
|
|
4
|
+
*
|
|
5
|
+
* TWO stores, split by trust:
|
|
6
|
+
* - WORKSPACE store `<projectCrewRoot(cwd)>/state/specs/<id>.json` — the
|
|
7
|
+
* generated/worker-influenced store. NEVER trusted by the strict gate.
|
|
8
|
+
* - USER store `~/.pi/agent/specs/<projectSlug>/<id>.json` — the ONLY store a
|
|
9
|
+
* user action (CLI import / explicit command) mints into. Trust lives here
|
|
10
|
+
* because `.crew/state/` is worker-writable runtime state: any prompt-injected
|
|
11
|
+
* worker can write a workspace `*.json` + `*.trusted` pair (round-1 P1).
|
|
12
|
+
* Raising the anchor out of the workspace makes workspace tampering
|
|
13
|
+
* structurally insufficient — no documented worker path writes `~/.pi/agent/specs`.
|
|
14
|
+
*
|
|
15
|
+
* Digest-bound sidecar: the user-store `<id>.trusted` marker contains the
|
|
16
|
+
* sha-256 of the canonical record JSON. `isSpecTrusted` verifies the binding,
|
|
17
|
+
* so editing a legitimately-minted record while keeping its real sidecar
|
|
18
|
+
* (content-swap attack) degrades to untrusted. Residual (documented in ADR
|
|
19
|
+
* erratum): a fully malicious same-user process can forge both files in
|
|
20
|
+
* `~/.pi/agent/specs` — but such a process already has arbitrary exec as the
|
|
21
|
+
* user; the gate defends against prompt-injected WORKERS via documented paths.
|
|
22
|
+
*
|
|
23
|
+
* Trust is evaluated ONCE at freeze (dispatch): `freezeSpecSnapshot` records
|
|
24
|
+
* `trustedAtFreeze` into the snapshot, and the strict gate reads ONLY that
|
|
25
|
+
* frozen bit — post-freeze mint/delete cannot affect a running task (TOCTOU
|
|
26
|
+
* window closed, round-1 P1 variant c).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { createHash } from "node:crypto";
|
|
30
|
+
import * as fs from "node:fs";
|
|
31
|
+
import * as os from "node:os";
|
|
32
|
+
import * as path from "node:path";
|
|
33
|
+
import { projectCrewRoot } from "../../utils/paths.ts";
|
|
34
|
+
import { atomicWriteJson } from "../atomic-write.ts";
|
|
35
|
+
import type { SpecRecord, SpecSnapshot } from "../types.ts";
|
|
36
|
+
|
|
37
|
+
function specsDir(cwd: string): string {
|
|
38
|
+
return path.join(projectCrewRoot(cwd), "state", "specs");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function userSpecsDir(cwd: string): string {
|
|
42
|
+
const slugSource = (() => {
|
|
43
|
+
try {
|
|
44
|
+
return fs.realpathSync(path.resolve(cwd));
|
|
45
|
+
} catch {
|
|
46
|
+
return path.resolve(cwd);
|
|
47
|
+
}
|
|
48
|
+
})();
|
|
49
|
+
const slug = createHash("sha256").update(slugSource).digest("hex").slice(0, 16);
|
|
50
|
+
return path.join(os.homedir(), ".pi", "agent", "specs", slug);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function recordPath(dir: string, id: string): string {
|
|
54
|
+
assertSafeSpecId(id);
|
|
55
|
+
return path.join(dir, `${id}.json`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sidecarPath(dir: string, id: string): string {
|
|
59
|
+
assertSafeSpecId(id);
|
|
60
|
+
return path.join(dir, `${id}.trusted`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SPEC_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
64
|
+
|
|
65
|
+
function assertSafeSpecId(id: string): void {
|
|
66
|
+
if (!SPEC_ID_PATTERN.test(id)) throw new Error(`Invalid spec id: ${id}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Stable canonical JSON (sorted keys, recursive) — the digest basis. */
|
|
70
|
+
export function canonicalSpecJson(value: unknown): string {
|
|
71
|
+
const sort = (v: unknown): unknown => {
|
|
72
|
+
if (Array.isArray(v)) return v.map(sort);
|
|
73
|
+
if (v && typeof v === "object") {
|
|
74
|
+
return Object.fromEntries(
|
|
75
|
+
Object.entries(v as Record<string, unknown>)
|
|
76
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
77
|
+
.map(([k, val]) => [k, sort(val)]),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return v;
|
|
81
|
+
};
|
|
82
|
+
return JSON.stringify(sort(value));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface SaveSpecOptions {
|
|
86
|
+
/** Only USER-facing actions (CLI import / explicit user command) may mint. */
|
|
87
|
+
userAction?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function saveSpecRecord(cwd: string, record: SpecRecord, options: SaveSpecOptions = {}): SpecRecord {
|
|
91
|
+
assertSafeSpecId(record.id);
|
|
92
|
+
const userAction = options.userAction === true;
|
|
93
|
+
if (userAction && record.source.kind === "manual") {
|
|
94
|
+
// USER store mint — the only trusted path. persisted.trusted mirrors the
|
|
95
|
+
// digest sidecar (informational); the gate reads the sidecar digest.
|
|
96
|
+
const persisted: SpecRecord = { ...record, trusted: true };
|
|
97
|
+
const dir = userSpecsDir(cwd);
|
|
98
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
99
|
+
atomicWriteJson(recordPath(dir, record.id), persisted);
|
|
100
|
+
const digest = createHash("sha256").update(canonicalSpecJson(persisted), "utf8").digest("hex");
|
|
101
|
+
fs.writeFileSync(sidecarPath(dir, record.id), `${digest}\n`, "utf-8");
|
|
102
|
+
return persisted;
|
|
103
|
+
}
|
|
104
|
+
// Workspace store — the worker/skill path AND user-imported generated
|
|
105
|
+
// records. ALWAYS persisted generated + untrusted regardless of payload.
|
|
106
|
+
const persisted: SpecRecord = {
|
|
107
|
+
...record,
|
|
108
|
+
source: { ...record.source, kind: "generated" },
|
|
109
|
+
trusted: false,
|
|
110
|
+
};
|
|
111
|
+
fs.mkdirSync(specsDir(cwd), { recursive: true });
|
|
112
|
+
atomicWriteJson(recordPath(specsDir(cwd), record.id), persisted);
|
|
113
|
+
// Teardown any stale workspace sidecar (defense in depth: a hand-forged
|
|
114
|
+
// workspace sidecar must never resurrect trust).
|
|
115
|
+
try {
|
|
116
|
+
fs.unlinkSync(sidecarPath(specsDir(cwd), record.id));
|
|
117
|
+
} catch {
|
|
118
|
+
/* absent — fine */
|
|
119
|
+
}
|
|
120
|
+
return persisted;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readRecordAt(file: string, id: string): SpecRecord | undefined {
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as SpecRecord;
|
|
126
|
+
if (parsed?.id !== id) return undefined;
|
|
127
|
+
return parsed;
|
|
128
|
+
} catch {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** User store first (authoritative for trusted specs), then workspace.
|
|
134
|
+
* Invalid/unsafe ids degrade to undefined (read path never throws). */
|
|
135
|
+
export function loadSpecRecord(cwd: string, id: string): SpecRecord | undefined {
|
|
136
|
+
if (!SPEC_ID_PATTERN.test(id)) return undefined;
|
|
137
|
+
const fromUser = readRecordAt(recordPath(userSpecsDir(cwd), id), id);
|
|
138
|
+
if (fromUser) return fromUser;
|
|
139
|
+
return readRecordAt(recordPath(specsDir(cwd), id), id);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Strict-gate trust check — USER-store sidecar exists AND its sha-256 digest
|
|
143
|
+
* binds to the record content. When `record` is provided (freeze path) the
|
|
144
|
+
* binding is checked against THAT record, killing the content-swap attack on
|
|
145
|
+
* legitimately-minted specs. */
|
|
146
|
+
export function isSpecTrusted(cwd: string, id: string, record?: SpecRecord): boolean {
|
|
147
|
+
if (!SPEC_ID_PATTERN.test(id)) return false;
|
|
148
|
+
const dir = userSpecsDir(cwd);
|
|
149
|
+
const candidate = record ?? readRecordAt(recordPath(dir, id), id);
|
|
150
|
+
if (!candidate) return false;
|
|
151
|
+
try {
|
|
152
|
+
const sidecar = fs.readFileSync(sidecarPath(dir, id), "utf-8").trim();
|
|
153
|
+
const digest = createHash("sha256").update(canonicalSpecJson(candidate), "utf8").digest("hex");
|
|
154
|
+
return sidecar === digest;
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function listSpecIds(cwd: string): string[] {
|
|
161
|
+
const ids = new Set<string>();
|
|
162
|
+
for (const dir of [userSpecsDir(cwd), specsDir(cwd)]) {
|
|
163
|
+
try {
|
|
164
|
+
for (const f of fs.readdirSync(dir)) {
|
|
165
|
+
if (!f.endsWith(".json")) continue;
|
|
166
|
+
const id = f.slice(0, -".json".length);
|
|
167
|
+
if (SPEC_ID_PATTERN.test(id)) ids.add(id);
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
/* absent dir — fine */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return [...ids];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Freeze the current record into an immutable SpecSnapshot (dispatch time).
|
|
177
|
+
* `trustedAtFreeze` is decided HERE — the strict gate never re-reads the live
|
|
178
|
+
* sidecar, so post-freeze mint/delete cannot change a running task's trust. */
|
|
179
|
+
export function freezeSpecSnapshot(record: SpecRecord, cwd: string): SpecSnapshot {
|
|
180
|
+
return {
|
|
181
|
+
specId: record.id,
|
|
182
|
+
version: record.version,
|
|
183
|
+
frozenAt: new Date().toISOString(),
|
|
184
|
+
trustedAtFreeze: isSpecTrusted(cwd, record.id, record),
|
|
185
|
+
items: record.requirements.flatMap((requirement) =>
|
|
186
|
+
record.acceptance.filter((a) => a.requirementId === requirement.id).map((acceptance) => ({ requirement, acceptance })),
|
|
187
|
+
),
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -17,13 +17,15 @@ import {
|
|
|
17
17
|
flushPendingAtomicWrites,
|
|
18
18
|
readJsonFile,
|
|
19
19
|
} from "../atomic-write.ts";
|
|
20
|
-
import { canTransitionRunStatus
|
|
20
|
+
import { canTransitionRunStatus } from "../contracts.ts";
|
|
21
21
|
import { withRunLock, withRunLockSync } from "../coordination/locks.ts";
|
|
22
22
|
import { appendEvent } from "../event-log/event-log.ts";
|
|
23
|
-
import { reconstructTasksFromEvents } from "../event-log/event-reconstructor.ts";
|
|
24
23
|
import type { TeamRunManifest, TeamTaskState } from "../types.ts";
|
|
25
|
-
import { CURRENT_SCHEMA_VERSION
|
|
24
|
+
import { CURRENT_SCHEMA_VERSION } from "../types.ts";
|
|
26
25
|
import { unregisterActiveRun } from "./active-run-registry.ts";
|
|
26
|
+
import { extractTaskArray, loadTasksWithRecovery, loadTasksWithRecoveryAsync, quarantineCorruptFile } from "./manifest-io.ts";
|
|
27
|
+
|
|
28
|
+
export { loadManifestWithRecovery, loadTasksWithRecovery } from "./manifest-io.ts";
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
31
|
* stat() the manifest with a brief retry on Windows for the AV-scan window.
|
|
@@ -87,6 +89,10 @@ export interface ManifestCacheEntry {
|
|
|
87
89
|
// (e.g., parent + child processes writing to the same run) relies on the
|
|
88
90
|
// mtime/size checks in loadRunManifestById, not on this counter.
|
|
89
91
|
const manifestCacheGeneration = new Map<string, number>();
|
|
92
|
+
// R5-M1 (Round 5 MEDIUM-1): safety-net FIFO cap. stateRoots are few in
|
|
93
|
+
// practice, but nothing else bounded this Map — it leaked ~1 entry per run
|
|
94
|
+
// for the process lifetime (manifestCache itself has TTL + LRU eviction).
|
|
95
|
+
const MANIFEST_CACHE_GENERATION_MAX = 64;
|
|
90
96
|
function genOf(stateRoot: string): number {
|
|
91
97
|
return manifestCacheGeneration.get(stateRoot) ?? 0;
|
|
92
98
|
}
|
|
@@ -151,7 +157,20 @@ function invalidateRunCache(stateRoot: string): void {
|
|
|
151
157
|
// F1: only bump the generation of THIS stateRoot — sibling runs keep their
|
|
152
158
|
// generation intact, so concurrent writes to run A no longer evict run B's
|
|
153
159
|
// hot cache.
|
|
154
|
-
|
|
160
|
+
// R5-M1: delete-then-set couples this generation entry to the cache entry it
|
|
161
|
+
// guards (same lifecycle point) and refreshes its insertion order, so the
|
|
162
|
+
// FIFO safety net below evicts the least-recently-invalidated stateRoot.
|
|
163
|
+
const nextGen = genOf(stateRoot) + 1;
|
|
164
|
+
manifestCacheGeneration.delete(stateRoot);
|
|
165
|
+
manifestCacheGeneration.set(stateRoot, nextGen);
|
|
166
|
+
while (manifestCacheGeneration.size > MANIFEST_CACHE_GENERATION_MAX) {
|
|
167
|
+
const oldest = manifestCacheGeneration.keys().next().value;
|
|
168
|
+
if (oldest === undefined) break;
|
|
169
|
+
// Dropping a generation entry makes genOf read 0 — also drop that root's
|
|
170
|
+
// manifest cache entry so a 0-stamped entry can never falsely match.
|
|
171
|
+
manifestCache.delete(oldest);
|
|
172
|
+
manifestCacheGeneration.delete(oldest);
|
|
173
|
+
}
|
|
155
174
|
}
|
|
156
175
|
|
|
157
176
|
function scopeBaseRoot(cwd: string): string {
|
|
@@ -241,7 +260,42 @@ export function createRunPaths(cwd: string, runId = createRunId()): RunPaths {
|
|
|
241
260
|
};
|
|
242
261
|
}
|
|
243
262
|
|
|
244
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Trailing phrases a step template leaves in front of `{goal}` — "Explore
|
|
265
|
+
* the codebase for the goal: {goal}" should name the task ("Explore the
|
|
266
|
+
* codebase"), not quote the whole run goal after every verb. The run goal is
|
|
267
|
+
* run-level context (statusline shows it once); a task list that repeats it
|
|
268
|
+
* per row reads like a roster of workers, not a list of work.
|
|
269
|
+
*/
|
|
270
|
+
const GOAL_TAILS = ["for the goal", "for goal", "the goal", "goal", "cho mục tiêu", "của mục tiêu", "mục tiêu", "for", "of", "cho", "của"];
|
|
271
|
+
|
|
272
|
+
function stripGoalTail(prefix: string): string {
|
|
273
|
+
let name = prefix.replace(/[\s:—-]+$/, "").trim();
|
|
274
|
+
let changed = true;
|
|
275
|
+
while (changed) {
|
|
276
|
+
changed = false;
|
|
277
|
+
const lowered = name.toLowerCase();
|
|
278
|
+
for (const tail of GOAL_TAILS) {
|
|
279
|
+
if (lowered.endsWith(tail) && lowered.length > tail.length) {
|
|
280
|
+
name = name
|
|
281
|
+
.slice(0, name.length - tail.length)
|
|
282
|
+
.replace(/[\s:—-]+$/, "")
|
|
283
|
+
.trim();
|
|
284
|
+
changed = true;
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return name;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function createTasksFromWorkflow(
|
|
293
|
+
runId: string,
|
|
294
|
+
workflow: WorkflowConfig,
|
|
295
|
+
team: TeamConfig,
|
|
296
|
+
cwd: string,
|
|
297
|
+
goal?: string,
|
|
298
|
+
): TeamTaskState[] {
|
|
245
299
|
const stepToTaskId = new Map(workflow.steps.map((step, index) => [step.id, createTaskId(step.id, index)]));
|
|
246
300
|
return workflow.steps.map((step, index) => {
|
|
247
301
|
const role = team.roles.find((candidate) => candidate.name === step.role);
|
|
@@ -251,13 +305,48 @@ export function createTasksFromWorkflow(runId: string, workflow: WorkflowConfig,
|
|
|
251
305
|
.filter((candidate) => candidate.dependsOn?.includes(step.id))
|
|
252
306
|
.map((candidate) => stepToTaskId.get(candidate.id))
|
|
253
307
|
.filter((childId): childId is string => childId !== undefined);
|
|
308
|
+
// The plan's own words become the task's display identity: a heading
|
|
309
|
+
// from the step body if present, else its first content line; the
|
|
310
|
+
// remaining body is kept as `description` for detail surfaces (task
|
|
311
|
+
// list detail line, view). Falls back to the step id when the body
|
|
312
|
+
// carries no text of its own — `task` is optional on literal
|
|
313
|
+
// WorkflowStep objects.
|
|
314
|
+
//
|
|
315
|
+
// `{goal}` in the title line is the RUN's goal, context for every
|
|
316
|
+
// task, not this task's name. The template prefix before the
|
|
317
|
+
// placeholder names the task; a placeholder-only body ("{goal}",
|
|
318
|
+
// direct-run) adopts the goal itself as the title. `description`
|
|
319
|
+
// lines substitute the placeholder (the prompt path substitutes
|
|
320
|
+
// separately — unchanged).
|
|
321
|
+
const bodyLines =
|
|
322
|
+
typeof step.task === "string" && step.task.trim()
|
|
323
|
+
? step.task
|
|
324
|
+
.split("\n")
|
|
325
|
+
.map((line) => line.trim())
|
|
326
|
+
.filter(Boolean)
|
|
327
|
+
: [];
|
|
328
|
+
const heading = bodyLines.find((line) => line.startsWith("#"));
|
|
329
|
+
const titleLine = heading ?? bodyLines[0];
|
|
330
|
+
const substitute = (text: string): string => (goal ? text.replaceAll("{goal}", goal) : text);
|
|
331
|
+
let title: string;
|
|
332
|
+
let restLines: string[];
|
|
333
|
+
const placeholderAt = titleLine?.indexOf("{goal}") ?? -1;
|
|
334
|
+
if (titleLine && placeholderAt >= 0) {
|
|
335
|
+
title = stripGoalTail(titleLine.slice(0, placeholderAt)) || goal || step.id;
|
|
336
|
+
restLines = bodyLines.filter((line) => line !== titleLine);
|
|
337
|
+
} else {
|
|
338
|
+
title = (titleLine ?? step.id).replace(/^#+\s*/, "");
|
|
339
|
+
restLines = heading ? bodyLines.filter((line) => line !== titleLine) : bodyLines.slice(1);
|
|
340
|
+
}
|
|
341
|
+
const description = restLines.map(substitute).join("\n");
|
|
254
342
|
return {
|
|
255
343
|
id,
|
|
256
344
|
runId,
|
|
257
345
|
stepId: step.id,
|
|
258
346
|
role: step.role,
|
|
259
347
|
agent: role?.agent ?? step.role,
|
|
260
|
-
title:
|
|
348
|
+
title: title.slice(0, 120),
|
|
349
|
+
description: description && description !== title ? description : undefined,
|
|
261
350
|
status: "queued",
|
|
262
351
|
dependsOn: dependencies,
|
|
263
352
|
cwd,
|
|
@@ -286,7 +375,7 @@ export function createRunManifest(params: {
|
|
|
286
375
|
}): { manifest: TeamRunManifest; tasks: TeamTaskState[]; paths: RunPaths } {
|
|
287
376
|
const paths = createRunPaths(params.cwd);
|
|
288
377
|
const now = new Date().toISOString();
|
|
289
|
-
const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd) : [];
|
|
378
|
+
const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd, params.goal) : [];
|
|
290
379
|
const manifest: TeamRunManifest = {
|
|
291
380
|
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
292
381
|
runId: paths.runId,
|
|
@@ -444,8 +533,13 @@ function shouldPersistTasks(manifest: TeamRunManifest, tasks: TeamTaskState[]):
|
|
|
444
533
|
if (tasks.length > 0) return true;
|
|
445
534
|
const existing = extractTaskArray(readJsonFile<unknown>(manifest.tasksPath));
|
|
446
535
|
if (existing.length > 0) {
|
|
447
|
-
|
|
448
|
-
|
|
536
|
+
logInternalError(
|
|
537
|
+
"state-store",
|
|
538
|
+
new Error(
|
|
539
|
+
`refusing to persist empty tasks over ${existing.length} existing task(s) — possible corrupt-load cascade (runId=${manifest.runId})`,
|
|
540
|
+
),
|
|
541
|
+
undefined,
|
|
542
|
+
"warn",
|
|
449
543
|
);
|
|
450
544
|
return false;
|
|
451
545
|
}
|
|
@@ -693,6 +787,7 @@ export function __test__manifestCacheSize(): number {
|
|
|
693
787
|
|
|
694
788
|
export function __test__clearManifestCache(): void {
|
|
695
789
|
manifestCache.clear();
|
|
790
|
+
manifestCacheGeneration.clear();
|
|
696
791
|
}
|
|
697
792
|
|
|
698
793
|
/**
|
|
@@ -707,17 +802,18 @@ export function __test__clearManifestCache(): void {
|
|
|
707
802
|
* `fs.statSync`-based invalidation. It is a safe exit hatch for callers that
|
|
708
803
|
* want a guaranteed cache drop (e.g. cleanup paths, end-of-run shutdown).
|
|
709
804
|
*
|
|
710
|
-
* Scope note
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
805
|
+
* Scope note (R10-2): the flush is scoped to the run's `tasks.json` — the only
|
|
806
|
+
* file that (a) feeds this cache and (b) can have a pending coalesced write
|
|
807
|
+
* (`manifest.json` is only ever written through immediate/durable paths, and
|
|
808
|
+
* `agents.json`/`status.json` coalesced writes live outside this cache).
|
|
809
|
+
* Unrelated coalesced writes for OTHER runs stay pending on their own timers;
|
|
810
|
+
* unloading one run must not block on them.
|
|
715
811
|
*/
|
|
716
812
|
export async function unloadRun(stateRoot: string): Promise<void> {
|
|
717
813
|
// Flush first so any in-flight buffered write lands on disk before we drop
|
|
718
814
|
// the cache entry. Otherwise a coalesced write could fire AFTER unloadRun
|
|
719
815
|
// returns, re-populate the cache from disk, and re-stale it.
|
|
720
|
-
flushPendingAtomicWrites();
|
|
816
|
+
flushPendingAtomicWrites(path.join(stateRoot, "tasks.json"));
|
|
721
817
|
// invalidateRunCache bumps the per-stateRoot generation counter so even if
|
|
722
818
|
// some in-process reader has a stale reference, the next cache lookup
|
|
723
819
|
// misses (generation mismatch) and re-reads from disk.
|
|
@@ -769,215 +865,6 @@ async function readJsonFileAsync<T>(filePath: string): Promise<T | undefined> {
|
|
|
769
865
|
}
|
|
770
866
|
}
|
|
771
867
|
|
|
772
|
-
/**
|
|
773
|
-
* ST-4: Rename a corrupt file to a quarantine path (`.corrupt-<ts>`) so it is
|
|
774
|
-
* preserved for debugging but no longer read as the primary source of truth.
|
|
775
|
-
*/
|
|
776
|
-
function quarantineCorruptFile(filePath: string): void {
|
|
777
|
-
try {
|
|
778
|
-
fs.renameSync(filePath, `${filePath}.corrupt-${Date.now()}`);
|
|
779
|
-
} catch {
|
|
780
|
-
// Best-effort — if rename fails (file already gone, permission, etc.),
|
|
781
|
-
// we still proceed with reconstruction from the event log.
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
/**
|
|
786
|
-
* ST-4: Convert event-reconstructor output into TeamTaskState[].
|
|
787
|
-
* Reconstructed tasks carry lifecycle data (id, status, timing) from the
|
|
788
|
-
* event log; auxiliary fields (role, agent, title) are filled with defaults
|
|
789
|
-
* since they are not present in lifecycle events.
|
|
790
|
-
*/
|
|
791
|
-
function reconstructTasksFromEventLog(eventsPath: string, runId: string): TeamTaskState[] {
|
|
792
|
-
try {
|
|
793
|
-
const result = reconstructTasksFromEvents(eventsPath);
|
|
794
|
-
const tasks: TeamTaskState[] = [];
|
|
795
|
-
for (const [, rt] of result.tasks) {
|
|
796
|
-
tasks.push({
|
|
797
|
-
id: rt.id,
|
|
798
|
-
runId,
|
|
799
|
-
role: "unknown",
|
|
800
|
-
agent: "unknown",
|
|
801
|
-
title: "reconstructed from events",
|
|
802
|
-
status: isTeamTaskStatus(rt.status) ? rt.status : "queued",
|
|
803
|
-
dependsOn: [],
|
|
804
|
-
cwd: "",
|
|
805
|
-
startedAt: rt.startedAt,
|
|
806
|
-
finishedAt: rt.finishedAt,
|
|
807
|
-
error: rt.error,
|
|
808
|
-
segment: rt.segment,
|
|
809
|
-
diagnostics: rt.diagnostics,
|
|
810
|
-
metrics: rt.metrics,
|
|
811
|
-
});
|
|
812
|
-
}
|
|
813
|
-
return tasks;
|
|
814
|
-
} catch {
|
|
815
|
-
return [];
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
/**
|
|
820
|
-
* ST-4: Load tasks.json with corruption recovery (sync path).
|
|
821
|
-
*
|
|
822
|
-
* Distinguishes:
|
|
823
|
-
* - ENOENT / ENOTDIR → legitimate empty → [] (NOT quarantined).
|
|
824
|
-
* - SyntaxError (parse failure) → corrupt → quarantine `.corrupt-<ts>` AND
|
|
825
|
-
* reconstruct from events.jsonl. If reconstruction yields tasks, persist
|
|
826
|
-
* them so subsequent loads see a valid file.
|
|
827
|
-
* - Non-array JSON (e.g. `{}`) → corrupt → same as SyntaxError.
|
|
828
|
-
* - Valid array → return as-is.
|
|
829
|
-
*/
|
|
830
|
-
|
|
831
|
-
/**
|
|
832
|
-
* ST-9: Extract the task array from a tasks.json payload.
|
|
833
|
-
*
|
|
834
|
-
* Accepts both the v0 legacy bare-array format and the v1+ envelope
|
|
835
|
-
* `{ schemaVersion, tasks }`. Returns [] for unrecognized shapes.
|
|
836
|
-
*/
|
|
837
|
-
function extractTaskArray(raw: unknown): TeamTaskState[] {
|
|
838
|
-
if (Array.isArray(raw)) return raw as TeamTaskState[];
|
|
839
|
-
if (raw !== null && typeof raw === "object" && "tasks" in raw) {
|
|
840
|
-
const envelope = raw as { tasks?: unknown };
|
|
841
|
-
if (Array.isArray(envelope.tasks)) return envelope.tasks as TeamTaskState[];
|
|
842
|
-
}
|
|
843
|
-
return [];
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
/**
|
|
847
|
-
* ST-9: Whether `parsed` is a recognizable tasks.json shape (v0 bare array
|
|
848
|
-
* or v1+ envelope). Used to distinguish legitimate formats from corruption.
|
|
849
|
-
*/
|
|
850
|
-
function isRecognizableTasksPayload(parsed: unknown): boolean {
|
|
851
|
-
if (Array.isArray(parsed)) return true;
|
|
852
|
-
if (parsed !== null && typeof parsed === "object" && "tasks" in parsed) {
|
|
853
|
-
return Array.isArray((parsed as { tasks?: unknown }).tasks);
|
|
854
|
-
}
|
|
855
|
-
return false;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
/**
|
|
859
|
-
* ST-9: Version-check + migration hook for tasks.json.
|
|
860
|
-
*
|
|
861
|
-
* tasks.json has two on-disk shapes:
|
|
862
|
-
* - v0 (current): bare JSON array `TeamTaskState[]` — what saveRunTasks*
|
|
863
|
-
* write today (backward-compatible; no schemaVersion envelope).
|
|
864
|
-
* - v1+ (future): envelope `{ schemaVersion: number, tasks: TeamTaskState[] }`
|
|
865
|
-
* — read-supported defensively for a future write-side switch.
|
|
866
|
-
*
|
|
867
|
-
* This detects the shape and returns the task array. v0 needs NO migration
|
|
868
|
-
* (it IS the current write format). For v1+ envelopes, a schemaVersion
|
|
869
|
-
* mismatch warns (mirroring the manifest check). Future breaking changes
|
|
870
|
-
* add real migration logic here.
|
|
871
|
-
*/
|
|
872
|
-
function migrateTasksFile(parsed: unknown, runId: string): TeamTaskState[] {
|
|
873
|
-
// v0 current: bare array (no schemaVersion envelope) — what writers produce.
|
|
874
|
-
if (Array.isArray(parsed)) {
|
|
875
|
-
// v0 bare array is the CURRENT write format (saveRunTasks* write the
|
|
876
|
-
// array directly, by design — backward compat). Nothing to migrate:
|
|
877
|
-
// return as-is. (v1+ envelope read-support below is defensive, for a
|
|
878
|
-
// future write-side switch.) Do NOT warn here — it would fire for 100%
|
|
879
|
-
// of runs on every load and flood the UI on startup.
|
|
880
|
-
return parsed as TeamTaskState[];
|
|
881
|
-
}
|
|
882
|
-
// v1+ envelope: { schemaVersion, tasks }.
|
|
883
|
-
if (parsed !== null && typeof parsed === "object" && "tasks" in parsed) {
|
|
884
|
-
const envelope = parsed as { schemaVersion?: unknown; tasks?: unknown };
|
|
885
|
-
const detected = typeof envelope.schemaVersion === "number" ? envelope.schemaVersion : 0;
|
|
886
|
-
if (detected !== CURRENT_TASKS_SCHEMA_VERSION) {
|
|
887
|
-
console.warn(
|
|
888
|
-
`[state-store] tasks.json schemaVersion mismatch: expected ${CURRENT_TASKS_SCHEMA_VERSION}, got ${detected}. Run ${runId} may be incompatible.`,
|
|
889
|
-
);
|
|
890
|
-
}
|
|
891
|
-
}
|
|
892
|
-
return extractTaskArray(parsed);
|
|
893
|
-
}
|
|
894
|
-
export function loadTasksWithRecovery(tasksPath: string, eventsPath: string, runId: string): TeamTaskState[] {
|
|
895
|
-
let content: string;
|
|
896
|
-
try {
|
|
897
|
-
content = fs.readFileSync(tasksPath, "utf-8");
|
|
898
|
-
} catch {
|
|
899
|
-
// ENOENT / ENOTDIR / other read errors → empty (retry loop handles
|
|
900
|
-
// transient instability; ENOENT is a legitimate empty run).
|
|
901
|
-
return [];
|
|
902
|
-
}
|
|
903
|
-
let parsed: unknown;
|
|
904
|
-
try {
|
|
905
|
-
parsed = JSON.parse(content);
|
|
906
|
-
} catch {
|
|
907
|
-
// SyntaxError — corrupt file.
|
|
908
|
-
quarantineCorruptFile(tasksPath);
|
|
909
|
-
const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
|
|
910
|
-
if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true });
|
|
911
|
-
return reconstructed;
|
|
912
|
-
}
|
|
913
|
-
if (!isRecognizableTasksPayload(parsed)) {
|
|
914
|
-
// Neither v0 bare array nor v1+ envelope (e.g. `{}`) — corrupt.
|
|
915
|
-
quarantineCorruptFile(tasksPath);
|
|
916
|
-
const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
|
|
917
|
-
if (reconstructed.length > 0) atomicWriteJson(tasksPath, reconstructed, { compact: true });
|
|
918
|
-
return reconstructed;
|
|
919
|
-
}
|
|
920
|
-
return migrateTasksFile(parsed, runId);
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
/**
|
|
924
|
-
* STATE-3: Load manifest.json with corruption quarantine (sync). Distinguishes:
|
|
925
|
-
* - ENOENT / read error → undefined (legitimate missing run — NOT quarantined).
|
|
926
|
-
* - SyntaxError (unparseable) → CORRUPT → quarantine `.corrupt-<ts>` + log + undefined.
|
|
927
|
-
* Manifest CANNOT be reconstructed from events.jsonl (run.created only carries
|
|
928
|
-
* {team, workflow}), so quarantine + visible log is the recovery — do NOT attempt
|
|
929
|
-
* reconstruction (unlike loadTasksWithRecovery). This prevents a corrupt manifest
|
|
930
|
-
* from silently making a run invisible (STATE-3).
|
|
931
|
-
*/
|
|
932
|
-
export function loadManifestWithRecovery(manifestPath: string, runId: string): TeamRunManifest | undefined {
|
|
933
|
-
let content: string;
|
|
934
|
-
try {
|
|
935
|
-
content = fs.readFileSync(manifestPath, "utf-8");
|
|
936
|
-
} catch {
|
|
937
|
-
// ENOENT / ENOTDIR / other read error → legitimate missing run.
|
|
938
|
-
return undefined;
|
|
939
|
-
}
|
|
940
|
-
try {
|
|
941
|
-
return JSON.parse(content) as TeamRunManifest;
|
|
942
|
-
} catch {
|
|
943
|
-
// SyntaxError → corrupt manifest. Quarantine (preserve for diagnosis) + log,
|
|
944
|
-
// then treat as missing. Do NOT reconstruct (infeasible from events).
|
|
945
|
-
quarantineCorruptFile(manifestPath);
|
|
946
|
-
console.error(
|
|
947
|
-
`[state-store] STATE-3: manifest.json for run ${runId} is corrupt (unparseable) — quarantined to ${manifestPath}.corrupt-*. Run is now treated as missing. Preserve the .corrupt-* file for diagnosis.`,
|
|
948
|
-
);
|
|
949
|
-
return undefined;
|
|
950
|
-
}
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
/**
|
|
954
|
-
* ST-4: async twin of {@link loadTasksWithRecovery}.
|
|
955
|
-
*/
|
|
956
|
-
async function loadTasksWithRecoveryAsync(tasksPath: string, eventsPath: string, runId: string): Promise<TeamTaskState[]> {
|
|
957
|
-
let content: string;
|
|
958
|
-
try {
|
|
959
|
-
content = await fs.promises.readFile(tasksPath, "utf-8");
|
|
960
|
-
} catch {
|
|
961
|
-
return [];
|
|
962
|
-
}
|
|
963
|
-
let parsed: unknown;
|
|
964
|
-
try {
|
|
965
|
-
parsed = JSON.parse(content);
|
|
966
|
-
} catch {
|
|
967
|
-
quarantineCorruptFile(tasksPath);
|
|
968
|
-
const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
|
|
969
|
-
if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true });
|
|
970
|
-
return reconstructed;
|
|
971
|
-
}
|
|
972
|
-
if (!isRecognizableTasksPayload(parsed)) {
|
|
973
|
-
quarantineCorruptFile(tasksPath);
|
|
974
|
-
const reconstructed = reconstructTasksFromEventLog(eventsPath, runId);
|
|
975
|
-
if (reconstructed.length > 0) await atomicWriteJsonAsync(tasksPath, reconstructed, { compact: true });
|
|
976
|
-
return reconstructed;
|
|
977
|
-
}
|
|
978
|
-
return migrateTasksFile(parsed, runId);
|
|
979
|
-
}
|
|
980
|
-
|
|
981
868
|
/**
|
|
982
869
|
* Load a run manifest and its tasks by runId.
|
|
983
870
|
* WARNING: This function provides best-effort consistency only. The sentinel-based
|
|
@@ -1094,8 +981,13 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
1094
981
|
// S-01: warn (do not throw) on schemaVersion mismatch — future version
|
|
1095
982
|
// bumps will add migration logic here.
|
|
1096
983
|
if (manifest && manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
|
|
1097
|
-
|
|
1098
|
-
|
|
984
|
+
logInternalError(
|
|
985
|
+
"state-store",
|
|
986
|
+
new Error(
|
|
987
|
+
`Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${manifest.schemaVersion}. Run ${runId} may be incompatible.`,
|
|
988
|
+
),
|
|
989
|
+
undefined,
|
|
990
|
+
"warn",
|
|
1099
991
|
);
|
|
1100
992
|
}
|
|
1101
993
|
// STATE-3: readJsonFile returns undefined for BOTH missing (ENOENT) and corrupt
|
|
@@ -1105,8 +997,13 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
1105
997
|
// infeasible (run.created lacks manifest fields), so quarantine+log is the recovery.
|
|
1106
998
|
if (!manifest && fs.existsSync(manifestPath)) {
|
|
1107
999
|
quarantineCorruptFile(manifestPath);
|
|
1108
|
-
|
|
1109
|
-
|
|
1000
|
+
logInternalError(
|
|
1001
|
+
"state-store",
|
|
1002
|
+
new Error(
|
|
1003
|
+
`STATE-3: manifest.json for run ${runId} exists but is unparseable — quarantined. Run treated as missing; preserve the .corrupt-* file for diagnosis.`,
|
|
1004
|
+
),
|
|
1005
|
+
undefined,
|
|
1006
|
+
"error",
|
|
1110
1007
|
);
|
|
1111
1008
|
return undefined;
|
|
1112
1009
|
}
|
|
@@ -1231,8 +1128,13 @@ export async function loadRunManifestByIdAsync(
|
|
|
1231
1128
|
// S-01: warn (do not throw) on schemaVersion mismatch — future version
|
|
1232
1129
|
// bumps will add migration logic here.
|
|
1233
1130
|
if (manifest && manifest.schemaVersion !== CURRENT_SCHEMA_VERSION) {
|
|
1234
|
-
|
|
1235
|
-
|
|
1131
|
+
logInternalError(
|
|
1132
|
+
"state-store",
|
|
1133
|
+
new Error(
|
|
1134
|
+
`Manifest schemaVersion mismatch: expected ${CURRENT_SCHEMA_VERSION}, got ${manifest.schemaVersion}. Run ${runId} may be incompatible.`,
|
|
1135
|
+
),
|
|
1136
|
+
undefined,
|
|
1137
|
+
"warn",
|
|
1236
1138
|
);
|
|
1237
1139
|
}
|
|
1238
1140
|
// STATE-3 (async twin): readJsonFileAsync returns undefined for BOTH missing (ENOENT)
|
|
@@ -1242,8 +1144,13 @@ export async function loadRunManifestByIdAsync(
|
|
|
1242
1144
|
// lacks manifest fields), so quarantine+log is the recovery.
|
|
1243
1145
|
if (!manifest && fs.existsSync(manifestPath)) {
|
|
1244
1146
|
quarantineCorruptFile(manifestPath);
|
|
1245
|
-
|
|
1246
|
-
|
|
1147
|
+
logInternalError(
|
|
1148
|
+
"state-store",
|
|
1149
|
+
new Error(
|
|
1150
|
+
`STATE-3 async: manifest.json for run ${runId} exists but is unparseable — quarantined. Run treated as missing; preserve the .corrupt-* file for diagnosis.`,
|
|
1151
|
+
),
|
|
1152
|
+
undefined,
|
|
1153
|
+
"error",
|
|
1247
1154
|
);
|
|
1248
1155
|
return undefined;
|
|
1249
1156
|
}
|