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,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WP-1/R1 — Unified agent identity (H6): the ownership map store.
|
|
3
|
+
*
|
|
4
|
+
* One ownership map PER RUN, persisted at `<manifest.stateRoot>/ownership-map.json`
|
|
5
|
+
* (stateRoot is already on `TeamRunManifest`). It records the
|
|
6
|
+
* `task ⇄ subagentId ⇄ pid ⇄ artifactsDir` links that both spawn paths contribute:
|
|
7
|
+
* - one-shot Agent-tool spawn (subagent-tools.ts): taskId + subagentId + artifactsDir (+ depth)
|
|
8
|
+
* - team-run child executor dispatch (child-executor.ts, onSpawn hook): taskId + pid + artifactsDir
|
|
9
|
+
*
|
|
10
|
+
* Because the file is scoped to a single run, `withRunLockSync(manifest)` (which
|
|
11
|
+
* locks `<stateRoot>/run.lock`) is the correct mutual-exclusion primitive: lock
|
|
12
|
+
* scope === file scope, so two runs never contend on the same lock for this file.
|
|
13
|
+
*
|
|
14
|
+
* DESIGN DECISION (documented deviation from the explorer-2 sketch): a
|
|
15
|
+
* workspace-wide map keyed by taskId was REJECTED because task ids are per-run
|
|
16
|
+
* (createTaskId output repeats across runs) — a workspace-wide
|
|
17
|
+
* `Record<taskId, entry>` would collide between concurrent runs, and a run-scoped
|
|
18
|
+
* lock would not serialize a workspace-scoped file. Consequently ALL read/write
|
|
19
|
+
* APIs are manifest-scoped; there is no cwd-only resolution path.
|
|
20
|
+
*
|
|
21
|
+
* Readers (widget/status/steer) use the read APIs, which are best-effort and
|
|
22
|
+
* lock-free (mirroring the `loadRunManifestById` precedent). Writers never throw
|
|
23
|
+
* (log + continue) — a failure must never break spawn/steer paths — and there is
|
|
24
|
+
* no `throw` inside any `finally`.
|
|
25
|
+
*/
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
29
|
+
import { atomicWriteJson, flushPendingAtomicWrites } from "../atomic-write.ts";
|
|
30
|
+
import { withRunLockSync } from "../coordination/locks.ts";
|
|
31
|
+
import type { TeamRunManifest } from "../types.ts";
|
|
32
|
+
|
|
33
|
+
/** One ownership link: task ⇄ subagentId ⇄ pid ⇄ artifacts dir, per run. */
|
|
34
|
+
export interface OwnershipEntry {
|
|
35
|
+
/** Key — team-run task id (createTaskId output; unique within a run). */
|
|
36
|
+
taskId: string;
|
|
37
|
+
/** The run this task belongs to (manifest.runId). */
|
|
38
|
+
runId: string;
|
|
39
|
+
/** Set by the one-shot Agent-tool spawn path (subagent-tools.ts). */
|
|
40
|
+
subagentId?: string;
|
|
41
|
+
/** Set by the child-executor onSpawn hook (real spawns only; last spawn
|
|
42
|
+
* attempt wins — model-fallback retries overwrite with the live pid). */
|
|
43
|
+
pid?: number;
|
|
44
|
+
/** manifest.artifactsRoot — where the task's steering/<taskId>.jsonl lives. */
|
|
45
|
+
artifactsDir: string;
|
|
46
|
+
/** Spawn depth (0 for a root one-shot). Optional for back-compat. */
|
|
47
|
+
depth?: number;
|
|
48
|
+
/** ISO-8601 timestamp; stamped at merge time when the caller omits it. */
|
|
49
|
+
updatedAt?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface OwnershipMapFile {
|
|
53
|
+
version: 1;
|
|
54
|
+
entries: Record<string, OwnershipEntry>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Fresh empty map — NEVER a shared singleton: callers may mutate the returned
|
|
58
|
+
* object's entries in upsert paths (fresh.entries[x] = ...), and a module-level
|
|
59
|
+
* constant would leak that mutation across runs (review finding R3: cross-run
|
|
60
|
+
* contamination of ownership-map.json). Allocate per call. */
|
|
61
|
+
function emptyOwnershipMap(): OwnershipMapFile {
|
|
62
|
+
return { version: 1, entries: {} };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Per-run ownership-map file, co-located with manifest.json / tasks.json. */
|
|
66
|
+
export function ownershipMapPath(manifest: TeamRunManifest): string {
|
|
67
|
+
return path.join(manifest.stateRoot, "ownership-map.json");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Best-effort read of the ownership map. Lock-free (readers only). ENOENT and
|
|
72
|
+
* corrupt JSON both yield the empty map — the store is regenerable, so it is
|
|
73
|
+
* never quarantined (unlike manifest STATE-3 handling). Never throws.
|
|
74
|
+
*/
|
|
75
|
+
export function readOwnershipMap(manifest: TeamRunManifest): OwnershipMapFile {
|
|
76
|
+
try {
|
|
77
|
+
const raw = fs.readFileSync(ownershipMapPath(manifest), "utf-8");
|
|
78
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
79
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyOwnershipMap();
|
|
80
|
+
const entries = (parsed as { entries?: unknown }).entries;
|
|
81
|
+
if (!entries || typeof entries !== "object" || Array.isArray(entries)) return emptyOwnershipMap();
|
|
82
|
+
return { version: 1, entries: entries as Record<string, OwnershipEntry> };
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
85
|
+
logInternalError("ownership-map.read", error, ownershipMapPath(manifest), "warn");
|
|
86
|
+
}
|
|
87
|
+
return emptyOwnershipMap();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Upsert one entry under the run lock (withRunLockSync + fresh-reload pattern,
|
|
93
|
+
* mirroring persistSingleTaskUpdate in state-helpers.ts).
|
|
94
|
+
*
|
|
95
|
+
* Merge-not-overwrite: only the provided fields change, so the one-shot writer
|
|
96
|
+
* (subagentId) and the dispatch writer (pid) never clobber each other.
|
|
97
|
+
* Fresh-reload INSIDE the lock (BUG-028 lesson) + atomicWriteJson (temp+rename)
|
|
98
|
+
* keeps the write crash-safe. Best-effort: never throws into spawn/steer paths.
|
|
99
|
+
*/
|
|
100
|
+
export function upsertOwnershipEntry(manifest: TeamRunManifest, entry: OwnershipEntry): void {
|
|
101
|
+
try {
|
|
102
|
+
withRunLockSync(manifest, () => {
|
|
103
|
+
// Defeat the atomic-write coalescer's stale-read window: force any
|
|
104
|
+
// pending buffered write for THIS file to land before we reload.
|
|
105
|
+
flushPendingAtomicWrites(ownershipMapPath(manifest));
|
|
106
|
+
const fresh = readOwnershipMap(manifest);
|
|
107
|
+
const prev = fresh.entries[entry.taskId] ?? {};
|
|
108
|
+
const merged: OwnershipEntry = {
|
|
109
|
+
...prev,
|
|
110
|
+
...entry,
|
|
111
|
+
taskId: entry.taskId,
|
|
112
|
+
updatedAt: entry.updatedAt ?? new Date().toISOString(),
|
|
113
|
+
};
|
|
114
|
+
// Merge contract: undefined-valued fields mean "not provided" — never
|
|
115
|
+
// persist them, or a later partial write (e.g. the dispatch writer
|
|
116
|
+
// adding pid) would clobber a value set by the other spawn path
|
|
117
|
+
// (e.g. subagentId). JSON.stringify would drop them anyway, but
|
|
118
|
+
// filtering here keeps the in-memory merge honest.
|
|
119
|
+
const clean = Object.fromEntries(Object.entries(merged).filter(([, value]) => value !== undefined)) as OwnershipEntry;
|
|
120
|
+
fresh.entries[entry.taskId] = clean;
|
|
121
|
+
atomicWriteJson(ownershipMapPath(manifest), fresh, { compact: true });
|
|
122
|
+
});
|
|
123
|
+
} catch (error) {
|
|
124
|
+
// Best-effort — a failure here must not break the spawn/steer caller.
|
|
125
|
+
logInternalError("ownership-map.write", error, `taskId=${entry.taskId}, runId=${entry.runId}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Resolve an entry by its task id (the map's key). Lock-free read. */
|
|
130
|
+
export function resolveEntryByTaskId(manifest: TeamRunManifest, taskId: string): OwnershipEntry | undefined {
|
|
131
|
+
return readOwnershipMap(manifest).entries[taskId];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Resolve an entry by subagent id (scan). Used by steer to find the taskId. */
|
|
135
|
+
export function resolveEntryBySubagentId(manifest: TeamRunManifest, subagentId: string): OwnershipEntry | undefined {
|
|
136
|
+
const entries = readOwnershipMap(manifest).entries;
|
|
137
|
+
for (const entry of Object.values(entries)) {
|
|
138
|
+
if (entry.subagentId === subagentId) return entry;
|
|
139
|
+
}
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Explorer-2 contract alias for the dispatch writer: `recordOwnership(cwd,
|
|
145
|
+
* manifest, entry)`. The per-run file location derives from the manifest alone
|
|
146
|
+
* (task ids are per-run, so a cwd-only path cannot locate the map), so `cwd` is
|
|
147
|
+
* retained only for caller ergonomics and is not used for path resolution.
|
|
148
|
+
*/
|
|
149
|
+
export function recordOwnership(_cwd: string, manifest: TeamRunManifest, entry: Partial<OwnershipEntry> & { taskId: string }): void {
|
|
150
|
+
const merged: OwnershipEntry = {
|
|
151
|
+
taskId: entry.taskId,
|
|
152
|
+
runId: entry.runId ?? manifest.runId,
|
|
153
|
+
artifactsDir: entry.artifactsDir ?? manifest.artifactsRoot,
|
|
154
|
+
updatedAt: entry.updatedAt ?? new Date().toISOString(),
|
|
155
|
+
};
|
|
156
|
+
// Only carry through the fields the caller actually provided — an explicit
|
|
157
|
+
// undefined must not clobber a value written by the other spawn path.
|
|
158
|
+
if (entry.subagentId !== undefined) merged.subagentId = entry.subagentId;
|
|
159
|
+
if (entry.pid !== undefined) merged.pid = entry.pid;
|
|
160
|
+
if (entry.depth !== undefined) merged.depth = entry.depth;
|
|
161
|
+
upsertOwnershipEntry(manifest, merged);
|
|
162
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T2/R4 (ADR-4 docs/decisions/2026-08-17-plan-object.md) — first-class Plan
|
|
3
|
+
* object: the revision-list store.
|
|
4
|
+
*
|
|
5
|
+
* One file per run at `<manifest.stateRoot>/plans/plans.json` holding an
|
|
6
|
+
* APPEND-ONLY list of PlanRecord snapshots. Because the file is run-scoped,
|
|
7
|
+
* `withRunLockSync(manifest)` is the correct mutual-exclusion primitive (lock
|
|
8
|
+
* scope === file scope — same reasoning as ownership-map.ts).
|
|
9
|
+
*
|
|
10
|
+
* Write discipline:
|
|
11
|
+
* - Revision/approval mutations append an event (ADR-4 §9: plan.created /
|
|
12
|
+
* plan.revised / plan.approved / plan.rejected / plan.item.dropped).
|
|
13
|
+
* - The scheduler's `items[].taskIds` linkage (linkTaskToPlanItem) is a
|
|
14
|
+
* single-writer mutation of the CURRENT revision only and appends NO event
|
|
15
|
+
* (task dispatch already logs its own worker.* events).
|
|
16
|
+
* - Reads are lock-free: atomicWriteJson's tmp+rename means readers observe
|
|
17
|
+
* either the full old or the full new file, never a torn one.
|
|
18
|
+
*
|
|
19
|
+
* Migration (ADR-4 §2): `manifest.plan` is the pointer to the current
|
|
20
|
+
* revision. If the pointer is absent but records exist (crash between the
|
|
21
|
+
* plans.json append and the manifest save), `getCurrentPlanRecord` degrades to
|
|
22
|
+
* the highest-version record — the crash window is benign. Readers that must
|
|
23
|
+
* behave like the pre-v2 gate use `effectivePlanApprovalPending`
|
|
24
|
+
* (plan-record-first, manifest-fallback).
|
|
25
|
+
*/
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as path from "node:path";
|
|
28
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
29
|
+
import { atomicWriteJson } from "../atomic-write.ts";
|
|
30
|
+
import { withRunLockSync } from "../coordination/locks.ts";
|
|
31
|
+
import { appendEvent } from "../event-log/event-log.ts";
|
|
32
|
+
import type { PlanItemRecord, PlanRecord, TeamRunManifest, TeamTaskState } from "../types.ts";
|
|
33
|
+
|
|
34
|
+
interface PlanFile {
|
|
35
|
+
version: 1;
|
|
36
|
+
revisions: PlanRecord[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PLAN_SUBPATH = path.join("plans", "plans.json");
|
|
40
|
+
|
|
41
|
+
export function planFilePath(manifest: TeamRunManifest): string {
|
|
42
|
+
return path.join(manifest.stateRoot, PLAN_SUBPATH);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Lock-free best-effort read: missing file → []; corrupt file → [] + internal
|
|
46
|
+
* error log (readers must degrade, never throw — `loadRunManifestById`
|
|
47
|
+
* precedent). Never returns a shared mutable object. */
|
|
48
|
+
export function loadPlanRecords(manifest: TeamRunManifest): PlanRecord[] {
|
|
49
|
+
try {
|
|
50
|
+
const raw = fs.readFileSync(planFilePath(manifest), "utf-8");
|
|
51
|
+
const parsed = JSON.parse(raw) as Partial<PlanFile>;
|
|
52
|
+
if (!parsed || !Array.isArray(parsed.revisions)) return [];
|
|
53
|
+
return parsed.revisions.filter((r): r is PlanRecord => Boolean(r?.id && typeof r.version === "number"));
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
56
|
+
logInternalError("plan-store.read-failed", err instanceof Error ? err : new Error(String(err)), `run=${manifest.runId}`);
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Current revision = manifest.plan pointer when resolvable; else the highest
|
|
62
|
+
* version (benign crash-window fallback, header note). */
|
|
63
|
+
export function getCurrentPlanRecord(manifest: TeamRunManifest): PlanRecord | undefined {
|
|
64
|
+
const revisions = loadPlanRecords(manifest);
|
|
65
|
+
if (revisions.length === 0) return undefined;
|
|
66
|
+
const pointer = manifest.plan;
|
|
67
|
+
if (pointer) {
|
|
68
|
+
const pointed = revisions.find((r) => r.id === pointer.id && r.version === pointer.version);
|
|
69
|
+
if (pointed) return pointed;
|
|
70
|
+
}
|
|
71
|
+
return revisions.reduce((acc, r) => (r.version > acc.version ? r : acc), revisions[0] as PlanRecord);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writePlanFile(manifest: TeamRunManifest, revisions: PlanRecord[]): void {
|
|
75
|
+
const file: PlanFile = { version: 1, revisions };
|
|
76
|
+
fs.mkdirSync(path.dirname(planFilePath(manifest)), { recursive: true });
|
|
77
|
+
atomicWriteJson(planFilePath(manifest), file);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** ADR-4 §3: item ids are unique per revision; carried-over items keep their
|
|
81
|
+
* id across revisions (producer contract checked here — duplicate ids reject
|
|
82
|
+
* the append rather than corrupt linkage). */
|
|
83
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
84
|
+
const TITLE_MAX = 512;
|
|
85
|
+
|
|
86
|
+
/** Security hardening (review S4/S5): producer-supplied ids/titles are
|
|
87
|
+
* untrusted worker-influenced text — constrain charset/length at the store
|
|
88
|
+
* boundary so a poisoned record can't smuggle control chars, path
|
|
89
|
+
* separators, or unbounded blobs into downstream renders and events. */
|
|
90
|
+
function assertRecordWellFormed(record: PlanRecord): void {
|
|
91
|
+
const ids = new Set<string>();
|
|
92
|
+
for (const item of record.items) {
|
|
93
|
+
if (!ID_PATTERN.test(item.id))
|
|
94
|
+
throw new Error(`plan-store: item id must match [A-Za-z0-9][A-Za-z0-9._-]{0,127} — got "${item.id.slice(0, 40)}"`);
|
|
95
|
+
if (item.title.length > TITLE_MAX) throw new Error(`plan-store: item title exceeds ${TITLE_MAX} chars`);
|
|
96
|
+
if (ids.has(item.id)) throw new Error(`plan-store: duplicate item id "${item.id}" in revision v${record.version}`);
|
|
97
|
+
ids.add(item.id);
|
|
98
|
+
}
|
|
99
|
+
const known = new Set(record.items.map((i) => i.id));
|
|
100
|
+
for (const phase of record.phases) {
|
|
101
|
+
for (const itemId of phase.itemIds) {
|
|
102
|
+
if (!known.has(itemId)) throw new Error(`plan-store: phase "${phase.id}" references unknown item "${itemId}"`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Append a new revision under the run lock (withRunLockSync + fresh reload).
|
|
109
|
+
* Lineage rules: first record has version 1 and no revisionOf; later records
|
|
110
|
+
* reuse the SAME id, version = last + 1, and revisionOf must match the last
|
|
111
|
+
* revision (auto-filled when omitted). Throws on violation — callers are
|
|
112
|
+
* producers inside team-runner and a corrupt lineage must fail loudly, not
|
|
113
|
+
* silently fork history.
|
|
114
|
+
* Returns the stored record. Emits `plan.created` (v1) or `plan.revised` (v2+).
|
|
115
|
+
*/
|
|
116
|
+
export function appendPlanRevision(manifest: TeamRunManifest, record: PlanRecord): PlanRecord {
|
|
117
|
+
return withRunLockSync(manifest, () => {
|
|
118
|
+
const revisions = loadPlanRecords(manifest);
|
|
119
|
+
if (revisions.length === 0) {
|
|
120
|
+
if (record.version !== 1) throw new Error(`plan-store: first revision must be v1, got v${record.version}`);
|
|
121
|
+
if (record.revisionOf) throw new Error("plan-store: first revision must not set revisionOf");
|
|
122
|
+
} else {
|
|
123
|
+
const last = revisions[revisions.length - 1] as PlanRecord;
|
|
124
|
+
if (record.id !== last.id) throw new Error(`plan-store: lineage break — expected id ${last.id}, got ${record.id}`);
|
|
125
|
+
if (record.version !== last.version + 1)
|
|
126
|
+
throw new Error(`plan-store: version must be ${last.version + 1}, got ${record.version}`);
|
|
127
|
+
if (!record.revisionOf) record.revisionOf = { id: last.id, version: last.version };
|
|
128
|
+
else if (record.revisionOf.id !== last.id || record.revisionOf.version !== last.version)
|
|
129
|
+
throw new Error(`plan-store: revisionOf must point at v${last.version} of ${last.id}`);
|
|
130
|
+
// ADR-4 §3 (review F1): item ids are stable across revisions — copy
|
|
131
|
+
// carried-over linkage forward INTO the new revision before it becomes
|
|
132
|
+
// current, under the same lock (producers never set taskIds; the union
|
|
133
|
+
// here is the scheduler-side re-link step the ADR specifies).
|
|
134
|
+
const carried = new Map(last.items.map((i) => [i.id, i.taskIds]));
|
|
135
|
+
for (const item of record.items) {
|
|
136
|
+
const prior = carried.get(item.id);
|
|
137
|
+
if (prior) item.taskIds = [...new Set([...prior, ...item.taskIds])];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
assertRecordWellFormed(record);
|
|
141
|
+
revisions.push(record);
|
|
142
|
+
writePlanFile(manifest, revisions);
|
|
143
|
+
const dropped = record.items.filter((i) => i.status === "dropped").length;
|
|
144
|
+
appendEvent(manifest.eventsPath, {
|
|
145
|
+
type: record.version === 1 ? "plan.created" : "plan.revised",
|
|
146
|
+
runId: manifest.runId,
|
|
147
|
+
message:
|
|
148
|
+
record.version === 1
|
|
149
|
+
? `Plan v1 created: ${record.items.length} item(s) in ${record.phases.length} phase(s)`
|
|
150
|
+
: `Plan v${record.version} revised (${record.items.length} item(s), ${dropped} dropped)`,
|
|
151
|
+
data: { planId: record.id, version: record.version, dropped },
|
|
152
|
+
});
|
|
153
|
+
return record;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* ADR-4 §3 single-writer linkage: append a taskId to the CURRENT revision's
|
|
159
|
+
* item (dedup; idempotent). Dropped items refuse linkage (they are never
|
|
160
|
+
* re-dispatched). No event — see header. Returns true when the file changed.
|
|
161
|
+
*/
|
|
162
|
+
export function linkTaskToPlanItem(manifest: TeamRunManifest, itemId: string, taskId: string): boolean {
|
|
163
|
+
return withRunLockSync(manifest, () => {
|
|
164
|
+
const revisions = loadPlanRecords(manifest);
|
|
165
|
+
if (revisions.length === 0) return false;
|
|
166
|
+
const current = revisions[revisions.length - 1] as PlanRecord;
|
|
167
|
+
const item = current.items.find((i: PlanItemRecord) => i.id === itemId);
|
|
168
|
+
if (!item || item.status === "dropped" || item.taskIds.includes(taskId)) return false;
|
|
169
|
+
item.taskIds.push(taskId);
|
|
170
|
+
writePlanFile(manifest, revisions);
|
|
171
|
+
return true;
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* ADR-4 §8 dual-write (record side): set approval on the current revision.
|
|
177
|
+
* `planVersion` must match the current revision — approving a superseded
|
|
178
|
+
* revision is a stale-write bug and throws. `plans reject` calls this with
|
|
179
|
+
* status "rejected" AND writes manifest.planApproval.status = "cancelled"
|
|
180
|
+
* (vocabulary mapping, §8). Emits plan.approved / plan.rejected.
|
|
181
|
+
*/
|
|
182
|
+
export function setPlanApproval(
|
|
183
|
+
manifest: TeamRunManifest,
|
|
184
|
+
approval: { status: "approved" | "rejected" | "pending"; by?: string; planVersion: number },
|
|
185
|
+
): PlanRecord | undefined {
|
|
186
|
+
return withRunLockSync(manifest, () => {
|
|
187
|
+
const revisions = loadPlanRecords(manifest);
|
|
188
|
+
if (revisions.length === 0) return undefined;
|
|
189
|
+
const current = revisions[revisions.length - 1] as PlanRecord;
|
|
190
|
+
if (approval.planVersion !== current.version)
|
|
191
|
+
throw new Error(`plan-store: approval targets v${approval.planVersion} but current is v${current.version}`);
|
|
192
|
+
current.approval = {
|
|
193
|
+
status: approval.status,
|
|
194
|
+
by: approval.by,
|
|
195
|
+
at: new Date().toISOString(),
|
|
196
|
+
planVersion: approval.planVersion,
|
|
197
|
+
};
|
|
198
|
+
writePlanFile(manifest, revisions);
|
|
199
|
+
// ADR-4 §9: events for approval MUTATIONS. `pending` emits none — the
|
|
200
|
+
// request surface (ensurePlanApprovalRequested) appends its own
|
|
201
|
+
// plan.approval_required event.
|
|
202
|
+
if (approval.status !== "pending") {
|
|
203
|
+
appendEvent(manifest.eventsPath, {
|
|
204
|
+
type: approval.status === "approved" ? "plan.approved" : "plan.rejected",
|
|
205
|
+
runId: manifest.runId,
|
|
206
|
+
message: `Plan v${approval.planVersion} ${approval.status}${approval.by ? ` by ${approval.by}` : ""}`,
|
|
207
|
+
data: { planId: current.id, version: approval.planVersion, status: approval.status },
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return current;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Derived, never stored (ADR-4 §1): per-item progress from linked task statuses. */
|
|
215
|
+
export interface PlanItemProgress {
|
|
216
|
+
itemId: string;
|
|
217
|
+
total: number;
|
|
218
|
+
done: number;
|
|
219
|
+
failed: number;
|
|
220
|
+
running: number;
|
|
221
|
+
pending: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function deriveItemProgress(record: PlanRecord, tasks: TeamTaskState[]): Map<string, PlanItemProgress> {
|
|
225
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
226
|
+
const out = new Map<string, PlanItemProgress>();
|
|
227
|
+
for (const item of record.items) {
|
|
228
|
+
const p: PlanItemProgress = { itemId: item.id, total: item.taskIds.length, done: 0, failed: 0, running: 0, pending: 0 };
|
|
229
|
+
for (const taskId of item.taskIds) {
|
|
230
|
+
const status = byId.get(taskId)?.status;
|
|
231
|
+
if (status === "completed") p.done++;
|
|
232
|
+
else if (status === "failed") p.failed++;
|
|
233
|
+
else if (status === "running" || status === "waiting" || status === "needs_attention") p.running++;
|
|
234
|
+
else if (status === "queued") p.pending++;
|
|
235
|
+
// cancelled/skipped: linked but resolved neither done nor pending — counted
|
|
236
|
+
// in `total` only (honest derivation; re-plan drops land here).
|
|
237
|
+
}
|
|
238
|
+
out.set(item.id, p);
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
@@ -6,8 +6,6 @@ import { atomicWriteJson } from "../atomic-write.ts";
|
|
|
6
6
|
import { withFileLockSync } from "../coordination/locks.ts";
|
|
7
7
|
import type { TeamTaskState } from "../types.ts";
|
|
8
8
|
|
|
9
|
-
const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
10
|
-
|
|
11
9
|
export interface CacheEntry {
|
|
12
10
|
key: string;
|
|
13
11
|
runId: string;
|
|
@@ -78,94 +76,6 @@ export function getCachedRun(cwd: string, cacheKey: string): CacheEntry | null {
|
|
|
78
76
|
}
|
|
79
77
|
}
|
|
80
78
|
|
|
81
|
-
/**
|
|
82
|
-
* Save run result to cache.
|
|
83
|
-
*/
|
|
84
|
-
export function saveRunToCache(
|
|
85
|
-
cwd: string,
|
|
86
|
-
cacheKey: string,
|
|
87
|
-
runId: string,
|
|
88
|
-
status: string,
|
|
89
|
-
tasks: TeamTaskState[],
|
|
90
|
-
goal: string,
|
|
91
|
-
team: string,
|
|
92
|
-
ttlMs: number = DEFAULT_CACHE_TTL_MS,
|
|
93
|
-
): void {
|
|
94
|
-
const dir = cacheDir(cwd);
|
|
95
|
-
|
|
96
|
-
if (!fs.existsSync(dir)) {
|
|
97
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const entry: CacheEntry = {
|
|
101
|
-
key: cacheKey,
|
|
102
|
-
runId,
|
|
103
|
-
status,
|
|
104
|
-
tasks,
|
|
105
|
-
cachedAt: Date.now(),
|
|
106
|
-
expiresAt: Date.now() + ttlMs,
|
|
107
|
-
goal,
|
|
108
|
-
team,
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
const entryPath = path.join(dir, `${cacheKey}.json`);
|
|
112
|
-
// ST-4: atomic entry write (was raw writeFileSync — inverted priority vs the index).
|
|
113
|
-
atomicWriteJson(entryPath, entry);
|
|
114
|
-
|
|
115
|
-
// Update index under a file lock (prevents concurrent saveCache from racing on the
|
|
116
|
-
// RMW) and write atomically (unique temp + rename — fixes the fixed "index.json.tmp"
|
|
117
|
-
// name collision the old manual temp+rename had).
|
|
118
|
-
const indexPath = path.join(dir, "index.json");
|
|
119
|
-
withFileLockSync(indexPath, () => {
|
|
120
|
-
// NEW-P4: TOCTOU fix — readFileSync + ENOENT catch instead of existsSync+read
|
|
121
|
-
// (1 syscall, no race). Only ENOENT falls back to {}; a corrupt-but-present
|
|
122
|
-
// index still throws, matching the old existsSync → JSON.parse behavior.
|
|
123
|
-
let index: CacheIndex = {};
|
|
124
|
-
try {
|
|
125
|
-
index = JSON.parse(fs.readFileSync(indexPath, "utf-8")) as CacheIndex;
|
|
126
|
-
} catch (error) {
|
|
127
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
128
|
-
}
|
|
129
|
-
index[cacheKey] = entryPath;
|
|
130
|
-
atomicWriteJson(indexPath, index);
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Clear all cache entries.
|
|
136
|
-
*/
|
|
137
|
-
export function clearCache(cwd: string): void {
|
|
138
|
-
const dir = cacheDir(cwd);
|
|
139
|
-
if (!fs.existsSync(dir)) return;
|
|
140
|
-
|
|
141
|
-
const indexPath = path.join(dir, "index.json");
|
|
142
|
-
if (fs.existsSync(indexPath)) {
|
|
143
|
-
try {
|
|
144
|
-
const index = JSON.parse(fs.readFileSync(indexPath, "utf-8")) as CacheIndex;
|
|
145
|
-
for (const entryPath of Object.values(index)) {
|
|
146
|
-
try {
|
|
147
|
-
fs.unlinkSync(entryPath);
|
|
148
|
-
} catch {
|
|
149
|
-
/* ignore */
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
fs.unlinkSync(indexPath);
|
|
153
|
-
} catch {
|
|
154
|
-
/* ignore */
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Remove entry files not in index
|
|
159
|
-
const entries = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
160
|
-
for (const entry of entries) {
|
|
161
|
-
try {
|
|
162
|
-
fs.unlinkSync(path.join(dir, entry));
|
|
163
|
-
} catch {
|
|
164
|
-
/* ignore */
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
79
|
/**
|
|
170
80
|
* Get cache stats.
|
|
171
81
|
*/
|