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,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wait-status-cache.ts — R10-3: mtime+size-gated load cache for task.waitStatus.
|
|
3
|
+
*
|
|
4
|
+
* Problem (docs/refactor-plan.review.md §Round 10, R10-3): the broker's
|
|
5
|
+
* task.waitStatus poll loop ticks every 200ms per waiter and each tick calls
|
|
6
|
+
* loadRunManifestById() — stat manifest + stat tasks + (on the state-store
|
|
7
|
+
* manifest-cache miss, which every write invalidates via generation bump) a
|
|
8
|
+
* full parse of BOTH manifest.json and tasks.json. With N concurrent waiters
|
|
9
|
+
* on a busy run that is ~5-10 syscalls × 5 ticks/sec × N.
|
|
10
|
+
*
|
|
11
|
+
* Fix: each BROKER INSTANCE holds one WaitStatusCache (Map keyed by runId —
|
|
12
|
+
* the broker serves a single cwd). Per tick it stats only manifest.json and
|
|
13
|
+
* tasks.json (via the exact same path derivation loadRunManifestById uses
|
|
14
|
+
* internally: createRunPaths → scopeBaseRoot(cwd)); the expensive
|
|
15
|
+
* loadRunManifestById() is invoked only when mtimeMs or size changed. All
|
|
16
|
+
* waiters on the broker share the entry, so a never-changing run costs ONE
|
|
17
|
+
* parse total (initial) instead of N×polls.
|
|
18
|
+
*
|
|
19
|
+
* Semantics vs the uncached path:
|
|
20
|
+
* - Observable behavior is identical: waiters still poll on the same 200ms
|
|
21
|
+
* schedule; they just avoid the parse when nothing changed. A status
|
|
22
|
+
* change on disk always lands via an atomic rename (new mtime), so the
|
|
23
|
+
* next tick re-parses and wakes the waiter within one poll (mtimeMs on
|
|
24
|
+
* Linux/macOS has sub-millisecond precision; on FAT-derived filesystems
|
|
25
|
+
* with coarse 1-2s mtime granularity staleness is bounded by that
|
|
26
|
+
* granularity — the same tradeoff the state-store manifestCache makes).
|
|
27
|
+
* - The actual parse is delegated to loadRunManifestById itself, so all of
|
|
28
|
+
* its correctness semantics (Windows stat retry, sentinel retry loop,
|
|
29
|
+
* STATE-3 quarantine, validateRunManifestPaths containment/symlink
|
|
30
|
+
* checks) still run at every reload boundary. Between reloads we serve
|
|
31
|
+
* the cached parse, exactly like the in-store manifestCache does.
|
|
32
|
+
* - A run whose files are missing gets a 0/0 stamp; while they stay
|
|
33
|
+
* missing, ticks return the cached undefined ("no-manifest" error),
|
|
34
|
+
* matching the uncached behavior of re-resolving to undefined.
|
|
35
|
+
*
|
|
36
|
+
* Memory: entries are evicted when a run's last broker connection closes
|
|
37
|
+
* (see crew-broker.ts cleanup) and by a hard FIFO cap, so long-lived
|
|
38
|
+
* brokers cannot grow the Map without bound across runs.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import * as fs from "node:fs";
|
|
42
|
+
import { createRunPaths, loadRunManifestById } from "../../state/stores/state-store.ts";
|
|
43
|
+
|
|
44
|
+
/** Shape returned by loadRunManifestById (derived — no duplicate type imports). */
|
|
45
|
+
type LoadedRun = ReturnType<typeof loadRunManifestById>;
|
|
46
|
+
|
|
47
|
+
/** Loader seam: defaults to the real loadRunManifestById; tests inject a spy. */
|
|
48
|
+
export type WaitStatusLoader = (cwd: string, runId: string) => LoadedRun;
|
|
49
|
+
|
|
50
|
+
interface WaitStatusCacheEntry {
|
|
51
|
+
manifestMtimeMs: number;
|
|
52
|
+
manifestSize: number;
|
|
53
|
+
tasksMtimeMs: number;
|
|
54
|
+
tasksSize: number;
|
|
55
|
+
loaded: LoadedRun;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Hard cap so a pathological broker (many runs, no close events) stays bounded. */
|
|
59
|
+
const WAIT_STATUS_CACHE_MAX = 128;
|
|
60
|
+
|
|
61
|
+
interface StatStamp {
|
|
62
|
+
manifestMtimeMs: number;
|
|
63
|
+
manifestSize: number;
|
|
64
|
+
tasksMtimeMs: number;
|
|
65
|
+
tasksSize: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Stat manifest+tasks; a missing/unstattable file is represented as 0/0. */
|
|
69
|
+
function statStamp(manifestPath: string, tasksPath: string): StatStamp {
|
|
70
|
+
let manifestMtimeMs = 0;
|
|
71
|
+
let manifestSize = 0;
|
|
72
|
+
let tasksMtimeMs = 0;
|
|
73
|
+
let tasksSize = 0;
|
|
74
|
+
try {
|
|
75
|
+
const st = fs.statSync(manifestPath);
|
|
76
|
+
manifestMtimeMs = st.mtimeMs;
|
|
77
|
+
manifestSize = st.size;
|
|
78
|
+
} catch {
|
|
79
|
+
/* missing or transient — treat as 0/0 */
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const st = fs.statSync(tasksPath);
|
|
83
|
+
tasksMtimeMs = st.mtimeMs;
|
|
84
|
+
tasksSize = st.size;
|
|
85
|
+
} catch {
|
|
86
|
+
/* missing or transient — treat as 0/0 */
|
|
87
|
+
}
|
|
88
|
+
return { manifestMtimeMs, manifestSize, tasksMtimeMs, tasksSize };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function sameStamp(a: StatStamp, b: StatStamp): boolean {
|
|
92
|
+
return (
|
|
93
|
+
a.manifestMtimeMs === b.manifestMtimeMs &&
|
|
94
|
+
a.manifestSize === b.manifestSize &&
|
|
95
|
+
a.tasksMtimeMs === b.tasksMtimeMs &&
|
|
96
|
+
a.tasksSize === b.tasksSize
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class WaitStatusCache {
|
|
101
|
+
private readonly entries = new Map<string, WaitStatusCacheEntry>();
|
|
102
|
+
private readonly loader: WaitStatusLoader;
|
|
103
|
+
private reloadsValue = 0;
|
|
104
|
+
|
|
105
|
+
constructor(options: { loader?: WaitStatusLoader } = {}) {
|
|
106
|
+
this.loader = options.loader ?? loadRunManifestById;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Diagnostic: number of times the (expensive) loader has been invoked. */
|
|
110
|
+
get reloads(): number {
|
|
111
|
+
return this.reloadsValue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Diagnostic: number of cached runs. */
|
|
115
|
+
get size(): number {
|
|
116
|
+
return this.entries.size;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Stat-gated load. Fast path (stamp unchanged): 2 statSync calls, zero
|
|
121
|
+
* parses. Slow path (first sight or changed mtime/size): delegate to the
|
|
122
|
+
* loader, then record the POST-load stamp so a concurrent writer that
|
|
123
|
+
* landed between our pre-load stat and the parse forces the next tick to
|
|
124
|
+
* reload instead of serving a stale entry.
|
|
125
|
+
*/
|
|
126
|
+
load(cwd: string, runId: string): LoadedRun {
|
|
127
|
+
// Same path derivation as resolveRunStateRoot inside
|
|
128
|
+
// loadRunManifestById (scopeBaseRoot(cwd) + runs subdir + contained
|
|
129
|
+
// runId) — createRunPaths exposes exactly that derivation publicly.
|
|
130
|
+
const paths = createRunPaths(cwd, runId);
|
|
131
|
+
const before = statStamp(paths.manifestPath, paths.tasksPath);
|
|
132
|
+
const cached = this.entries.get(runId);
|
|
133
|
+
if (cached && sameStamp(cached, before)) {
|
|
134
|
+
return cached.loaded;
|
|
135
|
+
}
|
|
136
|
+
const loaded = this.loader(cwd, runId);
|
|
137
|
+
this.reloadsValue += 1;
|
|
138
|
+
const after = statStamp(paths.manifestPath, paths.tasksPath);
|
|
139
|
+
if (this.entries.size >= WAIT_STATUS_CACHE_MAX && !this.entries.has(runId)) {
|
|
140
|
+
// Map preserves insertion order — evict the oldest entry.
|
|
141
|
+
const oldest = this.entries.keys().next().value;
|
|
142
|
+
if (oldest !== undefined) this.entries.delete(oldest);
|
|
143
|
+
}
|
|
144
|
+
this.entries.set(runId, { ...after, loaded });
|
|
145
|
+
return loaded;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Evict one run's entry (called when its last broker connection closes). */
|
|
149
|
+
delete(runId: string): void {
|
|
150
|
+
this.entries.delete(runId);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Drop everything (tests / broker stop). */
|
|
154
|
+
clear(): void {
|
|
155
|
+
this.entries.clear();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-task run budget enforcement for the team-run scheduler loop.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from team-runner.ts (2026-08, Phase 2.6 maintainability split —
|
|
5
|
+
* CORE-4 extraction 7 + the RT-NEW-2 terminaliseRunWithDrain/drainPendingUnits
|
|
6
|
+
* cluster it depends on). Pure code motion: checkPerTaskBudget /
|
|
7
|
+
* PerTaskBudgetCheckResult / DrainOutcome / drainPendingUnits /
|
|
8
|
+
* terminaliseRunWithDrain / enforceRunBudget moved verbatim.
|
|
9
|
+
*
|
|
10
|
+
* drainPendingUnits + terminaliseRunWithDrain land here (not a separate
|
|
11
|
+
* shared file) so budget-enforcement.ts stays self-contained and the
|
|
12
|
+
* team-runner.ts ↔ budget-enforcement.ts import direction stays one-way
|
|
13
|
+
* (team-runner imports from here; this module never imports from
|
|
14
|
+
* team-runner.ts — checkPerTaskBudget was moved here for exactly that
|
|
15
|
+
* reason: enforceRunBudget calls it, and importing it back from
|
|
16
|
+
* team-runner.ts would create a cycle).
|
|
17
|
+
*/
|
|
18
|
+
import { flushPendingAtomicWrites } from "../state/atomic-write.ts";
|
|
19
|
+
import { withRunLock } from "../state/coordination/locks.ts";
|
|
20
|
+
import { appendEventAsync } from "../state/event-log/event-log.ts";
|
|
21
|
+
import { loadRunManifestById, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
|
|
22
|
+
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
23
|
+
import { aggregateUsage, formatTokens } from "../state/usage.ts";
|
|
24
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
25
|
+
import { saveCrewAgents } from "./crew-agent-records.ts";
|
|
26
|
+
import { cancelNonTerminalTasks, markBlocked } from "./dispatch-batch.ts";
|
|
27
|
+
import { isNonTerminalTaskStatus, mergeTaskUpdatesPreservingTerminal } from "./merge-gate.ts";
|
|
28
|
+
import type { SchedulerContext, SchedulerDecision } from "./scheduler-context.ts";
|
|
29
|
+
import { recordsForMaterializedTasks } from "./task-display.ts";
|
|
30
|
+
import { mergeArtifacts } from "./team-runner-artifacts.ts";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Result of a per-task budget check against cumulative run usage.
|
|
34
|
+
*/
|
|
35
|
+
export interface PerTaskBudgetCheckResult {
|
|
36
|
+
/** Whether the abort threshold was exceeded. */
|
|
37
|
+
abort: boolean;
|
|
38
|
+
/** Whether the warning threshold was exceeded. */
|
|
39
|
+
warning: boolean;
|
|
40
|
+
/** IDs of tasks that exceeded their fair share (>50% of remaining budget). */
|
|
41
|
+
fairShareViolators: string[];
|
|
42
|
+
/** Total tokens used so far. */
|
|
43
|
+
totalUsed: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check cumulative token usage against per-task budget thresholds.
|
|
48
|
+
* Returns a structured result — callers decide how to act (warn vs abort).
|
|
49
|
+
*
|
|
50
|
+
* Exported for unit testing.
|
|
51
|
+
*/
|
|
52
|
+
export function checkPerTaskBudget(
|
|
53
|
+
tasks: TeamTaskState[],
|
|
54
|
+
budgetTotal: number,
|
|
55
|
+
budgetWarning: number,
|
|
56
|
+
budgetAbort: number,
|
|
57
|
+
fairShareFraction = 0.5,
|
|
58
|
+
): PerTaskBudgetCheckResult {
|
|
59
|
+
const usage = aggregateUsage(tasks);
|
|
60
|
+
const totalUsed = (usage?.input ?? 0) + (usage?.output ?? 0) + (usage?.cacheWrite ?? 0);
|
|
61
|
+
const abort = totalUsed >= budgetAbort * budgetTotal;
|
|
62
|
+
const warning = !abort && totalUsed >= budgetWarning * budgetTotal;
|
|
63
|
+
// Fair share threshold based on TOTAL budget, not remaining budget.
|
|
64
|
+
// This ensures a task that consumed 60% of total budget is flagged even
|
|
65
|
+
// if only 40% remains (40% * 50% = 20% threshold would miss the 60% usage).
|
|
66
|
+
const fairShareThreshold = budgetTotal * fairShareFraction;
|
|
67
|
+
const fairShareViolators: string[] = [];
|
|
68
|
+
for (const task of tasks) {
|
|
69
|
+
if (!task.usage) continue;
|
|
70
|
+
const taskTotal = (task.usage.input ?? 0) + (task.usage.output ?? 0) + (task.usage.cacheWrite ?? 0);
|
|
71
|
+
// Only flag tasks that individually consumed a significant portion of the
|
|
72
|
+
// budget (>10% of total) AND exceeded the fair share threshold.
|
|
73
|
+
if (fairShareThreshold > 0 && taskTotal > fairShareThreshold && taskTotal > budgetTotal * 0.1) {
|
|
74
|
+
fairShareViolators.push(task.id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { abort, warning, fairShareViolators, totalUsed };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Drain in-flight dispatch units (pendingUnits) by aborting the run-scoped
|
|
82
|
+
* controller and awaiting all settled promises before clearing the map.
|
|
83
|
+
*
|
|
84
|
+
* CORE-1 fix: without this, every early-return path inside the main while
|
|
85
|
+
* loop would abandon pendingUnits — leaving zombie child processes running
|
|
86
|
+
* with no one listening for their results.
|
|
87
|
+
*
|
|
88
|
+
* Exported so unit tests can exercise it directly.
|
|
89
|
+
*/
|
|
90
|
+
/** Settled outcome of a single in-flight dispatch unit promise (returned by drainPendingUnits). */
|
|
91
|
+
export type DrainOutcome = PromiseSettledResult<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }>;
|
|
92
|
+
|
|
93
|
+
export async function drainPendingUnits<
|
|
94
|
+
T extends { taskIds: string[]; promise: Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> },
|
|
95
|
+
>(pendingUnits: Map<string, T>, controller?: AbortController): Promise<DrainOutcome[]> {
|
|
96
|
+
if (pendingUnits.size === 0) return [];
|
|
97
|
+
controller?.abort();
|
|
98
|
+
const outcomes = await Promise.allSettled([...pendingUnits.values()].map((p) => p.promise));
|
|
99
|
+
pendingUnits.clear();
|
|
100
|
+
return outcomes;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* RT-NEW-2: terminalise a run as "failed" while draining in-flight dispatch
|
|
105
|
+
* units FIRST and merging their settled results under the run lock.
|
|
106
|
+
*
|
|
107
|
+
* Extracted verbatim from handleFailedTask (the FIXED reference) so every
|
|
108
|
+
* abort path behaves identically: drain pendingUnits (abort controller +
|
|
109
|
+
* await allSettled + clear), merge fulfilled outcomes into manifest/tasks
|
|
110
|
+
* under withRunLock (flushPendingAtomicWrites + loadRunManifestById +
|
|
111
|
+
* mergeArtifacts + mergeTaskUpdatesPreservingTerminal + save both), cancel
|
|
112
|
+
* (not skip) non-settled in-flight tasks so team resume can re-queue them,
|
|
113
|
+
* then markBlocked the remaining never-dispatched queued tasks.
|
|
114
|
+
*
|
|
115
|
+
* Previously enforceRunBudget skipped the drain+merge and called
|
|
116
|
+
* markBlocked directly — in-flight tasks (still "queued" in ctx.tasks since
|
|
117
|
+
* streaming dispatch never sets "running") were clobbered to "skipped",
|
|
118
|
+
* which team resume never re-queues → permanent work loss.
|
|
119
|
+
*/
|
|
120
|
+
export async function terminaliseRunWithDrain(
|
|
121
|
+
ctx: SchedulerContext,
|
|
122
|
+
opts: { cancelMessage: string; blockedMessage: string; failedReason: string },
|
|
123
|
+
): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> {
|
|
124
|
+
// Ever-dispatched tasks (monotonic set populated at dispatch). Using this
|
|
125
|
+
// instead of a pendingUnits snapshot closes the RT-NEW-2 race where a task
|
|
126
|
+
// whose unit settled + left pendingUnits before the abort — but whose task
|
|
127
|
+
// status isn't terminal yet — would otherwise fall through to markBlocked
|
|
128
|
+
// and be clobbered to "skipped" (observed CI flake: 02_b skipped on
|
|
129
|
+
// team-runner-budget-abort-inflight across v0.9.59 / cfd68d06 / 12386af2).
|
|
130
|
+
const inflightTaskIds = ctx.dispatchedTaskIds;
|
|
131
|
+
const outcomes = await drainPendingUnits(ctx.pendingUnits, ctx.runController);
|
|
132
|
+
const validResults: { manifest: TeamRunManifest; tasks: TeamTaskState[] }[] = [];
|
|
133
|
+
for (const outcome of outcomes) {
|
|
134
|
+
if (outcome.status === "fulfilled") validResults.push(outcome.value);
|
|
135
|
+
}
|
|
136
|
+
if (validResults.length > 0) {
|
|
137
|
+
// Merge under the run lock — same pattern as mergeUnitResult:
|
|
138
|
+
// flush pending writes, load disk state, merge artifacts + tasks,
|
|
139
|
+
// save atomically.
|
|
140
|
+
const mergeResult = await withRunLock(ctx.manifest, async () => {
|
|
141
|
+
flushPendingAtomicWrites();
|
|
142
|
+
const disk = loadRunManifestById(ctx.manifest.cwd, ctx.manifest.runId);
|
|
143
|
+
const diskManifest = disk?.manifest ?? ctx.manifest;
|
|
144
|
+
const reconciledArtifacts = mergeArtifacts([
|
|
145
|
+
...diskManifest.artifacts,
|
|
146
|
+
...validResults.flatMap((item) => item.manifest.artifacts),
|
|
147
|
+
]);
|
|
148
|
+
const resultManifest = updateRunStatus(
|
|
149
|
+
{ ...diskManifest, artifacts: reconciledArtifacts },
|
|
150
|
+
"running",
|
|
151
|
+
"Merged in-flight results during failed-task abort.",
|
|
152
|
+
);
|
|
153
|
+
const resultTasks = mergeTaskUpdatesPreservingTerminal(disk?.tasks ?? ctx.tasks, validResults);
|
|
154
|
+
await saveRunManifestAsync(resultManifest);
|
|
155
|
+
await saveRunTasksAsync(resultManifest, resultTasks);
|
|
156
|
+
return { resultManifest, resultTasks };
|
|
157
|
+
});
|
|
158
|
+
ctx.manifest = mergeResult.resultManifest;
|
|
159
|
+
ctx.tasks = mergeResult.resultTasks;
|
|
160
|
+
}
|
|
161
|
+
// Cancel in-flight tasks that did NOT settle (e.g. rejected promises)
|
|
162
|
+
// so team resume CAN re-queue them. markBlocked maps queued→skipped,
|
|
163
|
+
// which resume never re-queues — work would be lost permanently. Only
|
|
164
|
+
// cancel tasks that are both in-flight AND still non-terminal (settled
|
|
165
|
+
// tasks with a terminal status are preserved).
|
|
166
|
+
ctx.tasks = cancelNonTerminalTasks(
|
|
167
|
+
ctx.tasks,
|
|
168
|
+
"cancelled",
|
|
169
|
+
opts.cancelMessage,
|
|
170
|
+
(task) => inflightTaskIds.has(task.id) && isNonTerminalTaskStatus(task.status),
|
|
171
|
+
);
|
|
172
|
+
// Remaining queued tasks (never dispatched) → skipped (original behavior
|
|
173
|
+
// preserved for downstream tasks not yet in-flight).
|
|
174
|
+
ctx.tasks = markBlocked(ctx.tasks, opts.blockedMessage);
|
|
175
|
+
await saveRunTasksAsync(ctx.manifest, ctx.tasks);
|
|
176
|
+
saveCrewAgents(ctx.manifest, recordsForMaterializedTasks(ctx.manifest, ctx.tasks, ctx.runtimeKind));
|
|
177
|
+
ctx.manifest = updateRunStatus(ctx.manifest, "failed", opts.failedReason);
|
|
178
|
+
return { manifest: ctx.manifest, tasks: ctx.tasks };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* CORE-4 extraction 7: enforce per-task run budget after each batch merge.
|
|
183
|
+
*
|
|
184
|
+
* When `input.budgetTotal` is set (and not unlimited), checks cumulative usage
|
|
185
|
+
* against warn/abort thresholds and a fair-share heuristic. On abort, marks all
|
|
186
|
+
* non-terminal tasks blocked, persists the run as failed, and returns a
|
|
187
|
+
* `{ kind: "return" }` decision so the caller short-circuits the loop.
|
|
188
|
+
* Otherwise emits `run.budget_warning` / `task.budget_fair_share` events and
|
|
189
|
+
* returns null (continue).
|
|
190
|
+
*
|
|
191
|
+
* Reads `ctx.input` (budget config). Mutates `ctx.tasks` / `ctx.manifest` only
|
|
192
|
+
* in the abort path. The caller syncs these locals back after the call.
|
|
193
|
+
*
|
|
194
|
+
* @param ctx The scheduler context.
|
|
195
|
+
* @returns `{ kind: "return", result }` on budget abort; `null` otherwise.
|
|
196
|
+
*/
|
|
197
|
+
export async function enforceRunBudget(ctx: SchedulerContext): Promise<SchedulerDecision | null> {
|
|
198
|
+
const input = ctx.input;
|
|
199
|
+
const tasks = ctx.tasks;
|
|
200
|
+
const manifest = ctx.manifest;
|
|
201
|
+
// Per-task budget enforcement: check cumulative usage after each batch merge.
|
|
202
|
+
// This prevents a single task from consuming 100% of the budget before
|
|
203
|
+
// abort triggers (the goal-loop only checks at turn boundaries).
|
|
204
|
+
if (input.budgetTotal !== undefined && input.budgetTotal > 0 && input.budgetUnlimited !== true) {
|
|
205
|
+
const warnThreshold = input.budgetWarning ?? 0.8;
|
|
206
|
+
const abortThreshold = input.budgetAbort ?? 0.95;
|
|
207
|
+
const budgetCheck = checkPerTaskBudget(tasks, input.budgetTotal, warnThreshold, abortThreshold);
|
|
208
|
+
|
|
209
|
+
if (budgetCheck.abort) {
|
|
210
|
+
const message = `Per-task budget abort threshold exceeded: ${formatTokens(budgetCheck.totalUsed)}/${formatTokens(input.budgetTotal)} (${Math.round((budgetCheck.totalUsed / input.budgetTotal) * 100)}%)`;
|
|
211
|
+
// ROUND 12: console → logInternalError with explicit "warn" severity
|
|
212
|
+
// (run-termination event — operator channel; default "debug" is gated).
|
|
213
|
+
logInternalError("team-runner.budget-abort", new Error(message), `runId=${manifest.runId}`, "warn");
|
|
214
|
+
await appendEventAsync(manifest.eventsPath, {
|
|
215
|
+
type: "run.budget_abort",
|
|
216
|
+
runId: manifest.runId,
|
|
217
|
+
message,
|
|
218
|
+
data: {
|
|
219
|
+
budgetTotal: input.budgetTotal,
|
|
220
|
+
budgetUsed: budgetCheck.totalUsed,
|
|
221
|
+
threshold: "abort",
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
// RT-NEW-2: drain in-flight units + merge settled results before
|
|
225
|
+
// terminalising, so in-flight tasks become completed/cancelled (not
|
|
226
|
+
// skipped). Same shared helper handleFailedTask uses. Run-failed
|
|
227
|
+
// reason stays the budget message.
|
|
228
|
+
const result = await terminaliseRunWithDrain(ctx, {
|
|
229
|
+
cancelMessage: `Cancelled by budget abort: ${message}`,
|
|
230
|
+
blockedMessage: `Budget abort threshold exceeded: ${message}`,
|
|
231
|
+
failedReason: message,
|
|
232
|
+
});
|
|
233
|
+
return { kind: "return", result };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (budgetCheck.warning) {
|
|
237
|
+
const message = `Per-task budget warning threshold crossed: ${formatTokens(budgetCheck.totalUsed)}/${formatTokens(input.budgetTotal)} (${Math.round((budgetCheck.totalUsed / input.budgetTotal) * 100)}%)`;
|
|
238
|
+
// ROUND 12: console → logInternalError, explicit "warn" severity.
|
|
239
|
+
logInternalError("team-runner.budget-warning", new Error(message), `runId=${manifest.runId}`, "warn");
|
|
240
|
+
await appendEventAsync(manifest.eventsPath, {
|
|
241
|
+
type: "run.budget_warning",
|
|
242
|
+
runId: manifest.runId,
|
|
243
|
+
message,
|
|
244
|
+
data: {
|
|
245
|
+
budgetTotal: input.budgetTotal,
|
|
246
|
+
budgetUsed: budgetCheck.totalUsed,
|
|
247
|
+
threshold: "warning",
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Fair-share warning: flag tasks that consumed >50% of remaining budget
|
|
253
|
+
// without killing them mid-execution.
|
|
254
|
+
const fairShareAppends: Promise<void>[] = [];
|
|
255
|
+
for (const violatorId of budgetCheck.fairShareViolators) {
|
|
256
|
+
const violator = tasks.find((t) => t.id === violatorId);
|
|
257
|
+
if (!violator) continue;
|
|
258
|
+
const taskTotal = (violator.usage?.input ?? 0) + (violator.usage?.output ?? 0) + (violator.usage?.cacheWrite ?? 0);
|
|
259
|
+
const message = `Task '${violatorId}' consumed ${formatTokens(taskTotal)} (${Math.round((taskTotal / input.budgetTotal) * 100)}% of total budget) — exceeds fair share`;
|
|
260
|
+
// ROUND 12: console → logInternalError, explicit "warn" severity.
|
|
261
|
+
logInternalError("team-runner.fair-share", new Error(message), `runId=${manifest.runId} taskId=${violatorId}`, "warn");
|
|
262
|
+
fairShareAppends.push(
|
|
263
|
+
appendEventAsync(manifest.eventsPath, {
|
|
264
|
+
type: "task.budget_fair_share",
|
|
265
|
+
runId: manifest.runId,
|
|
266
|
+
taskId: violatorId,
|
|
267
|
+
message,
|
|
268
|
+
data: {
|
|
269
|
+
budgetTotal: input.budgetTotal,
|
|
270
|
+
taskUsage: taskTotal,
|
|
271
|
+
},
|
|
272
|
+
}).then(
|
|
273
|
+
() => undefined,
|
|
274
|
+
(error) => logInternalError("team-runner.fair-share-event", error, `taskId=${violatorId}`),
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
await Promise.all(fairShareAppends);
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
@@ -32,6 +32,14 @@ export const MAX_LINE_BUFFER_BYTES = 1024 * 1024; // 1 MB
|
|
|
32
32
|
/** Maximum characters for assistant text fragments in compacted events. */
|
|
33
33
|
export const MAX_ASSISTANT_TEXT_CHARS = DEFAULT_CHILD_PI.maxAssistantTextChars;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Maximum characters for reasoning (`thinking`) fragments in compacted events,
|
|
37
|
+
* and the size cap for the thinking parts in the agent view pane / view
|
|
38
|
+
* session. Reasoning is display-only (never folded into results or artifacts),
|
|
39
|
+
* so it gets a tighter cap than full assistant text.
|
|
40
|
+
*/
|
|
41
|
+
export const MAX_THINKING_CHARS = 8 * 1024;
|
|
42
|
+
|
|
35
43
|
/** Maximum characters for tool-result fragments in compacted events. */
|
|
36
44
|
export const MAX_TOOL_RESULT_CHARS = DEFAULT_CHILD_PI.maxToolResultChars;
|
|
37
45
|
|
|
@@ -71,8 +71,9 @@ export const BASE_ALLOWLIST: string[] = [
|
|
|
71
71
|
* 1. Validate cwd (realpath + isDirectory) — fall back to lexical path on ENOENT.
|
|
72
72
|
* 2. Filter env vars to the allowlist (model-aware provider key scoping).
|
|
73
73
|
* 3. Validate NODE_PATH against safe prefixes (/opt, /lib, /usr, /home).
|
|
74
|
-
* 4. Add PI_CREW_PARENT_PID for the child-side parent-guard (
|
|
75
|
-
*
|
|
74
|
+
* 4. Add PI_CREW_PARENT_PID for the child-side parent-guard (consumed by the
|
|
75
|
+
* reactive zombie-scanner + background-runner parent-guard; unused only by
|
|
76
|
+
* the pi dist binary — see note at the PI_CREW_PARENT_PID assignment below).
|
|
76
77
|
*/
|
|
77
78
|
export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, model?: string): SpawnOptions {
|
|
78
79
|
// SECURITY FIX (Issue #1): Validate cwd before passing to spawn.
|
|
@@ -137,11 +138,20 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, mo
|
|
|
137
138
|
cwd: validatedCwd,
|
|
138
139
|
env: {
|
|
139
140
|
...filteredEnv,
|
|
140
|
-
// PI_CREW_PARENT_PID is set so
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
// (
|
|
144
|
-
//
|
|
141
|
+
// PI_CREW_PARENT_PID is set so child workers can run a parent-guard
|
|
142
|
+
// (parent-guard.ts). Consumers (NOT dead):
|
|
143
|
+
// 1. zombie-scanner.ts:185 — reads PI_CREW_PARENT_PID from
|
|
144
|
+
// /proc/<pid>/environ (readProcEnviron) to detect orphaned/zombie
|
|
145
|
+
// workers whose leader died.
|
|
146
|
+
// 2. background-runner.ts:615 — startParentGuard(parentPid)
|
|
147
|
+
// self-terminates the orchestrator when its own parent dies.
|
|
148
|
+
// 3. scratchpad-lifecycle.ts:50,166 — propagates the leader pid into
|
|
149
|
+
// scratchpad guest env for guest-zombie detection.
|
|
150
|
+
// The var is unused ONLY by the external `pi` binary
|
|
151
|
+
// (@earendil-works/pi-coding-agent): it does NOT read PI_CREW_PARENT_PID
|
|
152
|
+
// or call startParentGuard (grep of Pi dist = 0 matches), so child-pi
|
|
153
|
+
// workers cannot self-terminate on leader death — they rely on the
|
|
154
|
+
// reactive zombie scanner (see docs/decisions/2026-08-14-parent-guard-reactive-scanner.md).
|
|
145
155
|
// The deeper fix (wiring startParentGuard into the pi worker entry point)
|
|
146
156
|
// is DEFERRED because workers are an external binary pi-crew doesn't control.
|
|
147
157
|
//
|
|
@@ -158,7 +168,7 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, mo
|
|
|
158
168
|
// NOTE: setsid creates a new session; the child process becomes the session leader
|
|
159
169
|
// and its parent becomes that session leader (still the team-runner in the same
|
|
160
170
|
// process group). PI_CREW_PARENT_PID is set before spawn using process.pid (team-runner),
|
|
161
|
-
// but see the comment above —
|
|
171
|
+
// but see the comment above — the pi worker binary does NOT actually consume it. The
|
|
162
172
|
// parent-guard model would check direct parent liveness via process.kill(pid, 0),
|
|
163
173
|
// but this is only implemented in background-runner.ts, not in the worker binary.
|
|
164
174
|
windowsHide: true,
|
|
@@ -246,6 +256,7 @@ export interface SpawnContext {
|
|
|
246
256
|
export function prepareSpawnContext(
|
|
247
257
|
input: ChildPiRunInput,
|
|
248
258
|
effectiveTask: string,
|
|
259
|
+
depthEnv?: NodeJS.ProcessEnv,
|
|
249
260
|
): { kind: "ready"; ctx: SpawnContext } | { kind: "aborted"; result: ChildPiRunResult } {
|
|
250
261
|
const built = buildPiWorkerArgs({
|
|
251
262
|
task: effectiveTask,
|
|
@@ -256,6 +267,10 @@ export function prepareSpawnContext(
|
|
|
256
267
|
skillPaths: input.skillPaths,
|
|
257
268
|
role: input.role,
|
|
258
269
|
thinkingOverride: input.thinkingOverride,
|
|
270
|
+
// ADR-5 §3: depthOverride is pre-encoded into depthEnv by runChildPi
|
|
271
|
+
// (parent's record depth as base-env PI_CREW_DEPTH); forward it so the
|
|
272
|
+
// child env gets parentDepth+1 = the true grandchild depth.
|
|
273
|
+
env: depthEnv,
|
|
259
274
|
});
|
|
260
275
|
// Pass steering file path to child for real-time steer injection
|
|
261
276
|
if (input.steeringFile) built.env.PI_CREW_STEERING_FILE = input.steeringFile;
|
|
@@ -265,6 +280,31 @@ export function prepareSpawnContext(
|
|
|
265
280
|
// assertOnlyControlEnvKeys.
|
|
266
281
|
if (input.runId) built.env.PI_CREW_BROKER_RUN_ID = input.runId;
|
|
267
282
|
if (input.agentId) built.env.PI_CREW_BROKER_TASK_ID = input.agentId;
|
|
283
|
+
// WP-2/R2 (ADR-0 item 2 — P0 env plumbing): UNCONDITIONAL for EVERY role,
|
|
284
|
+
// read-only (reviewer/security-reviewer/explorer/…) included — the
|
|
285
|
+
// worker-side `ask` tool + mailbox poll must be reachable by exactly the
|
|
286
|
+
// roles that cannot write files to unblock themselves. Deliberately NOT
|
|
287
|
+
// scratchpad-gated: the scratchpad gate below excludes read-only roles by
|
|
288
|
+
// design (S-6), which would leave the ask tool dead-on-arrival there.
|
|
289
|
+
// Control-namespace keys → pass assertOnlyControlEnvKeys.
|
|
290
|
+
built.env.PI_CREW_ASK_ENABLED = "1"; // dormant gate (worker conditional registerTool)
|
|
291
|
+
// T3/R5 (ADR-5 §1): the worker-side `delegate` tool is registered ONLY for
|
|
292
|
+
// executor-class roles at depth 1 (read-only roles are spawn-denied
|
|
293
|
+
// server-side anyway — the env gate is UX/dead-weight hygiene, not the
|
|
294
|
+
// security boundary; broker admission re-checks role+depth from the task
|
|
295
|
+
// RECORD). Control-namespace key → assertOnlyControlEnvKeys.
|
|
296
|
+
const childDepth = Number(built.env.PI_CREW_DEPTH ?? "1");
|
|
297
|
+
if (childDepth <= 1 && (input.role === "executor" || input.role === "test-engineer")) {
|
|
298
|
+
built.env.PI_CREW_DELEGATE_ENABLED = "1";
|
|
299
|
+
}
|
|
300
|
+
// stateRoot from the spawn manifest: ChildPiRunInput threads
|
|
301
|
+
// manifest.eventsPath unconditionally (child-executor / background-runner)
|
|
302
|
+
// and the state store pins eventsPath === <stateRoot>/events.jsonl
|
|
303
|
+
// (DEFAULT_PATHS.state.eventsFile; the invariant is validated by
|
|
304
|
+
// state-store.ts + manifest-cache.ts) — dirname(eventsPath) IS the manifest
|
|
305
|
+
// stateRoot. Absent (non-team spawn) → var stays unset and the ask tool
|
|
306
|
+
// fast-fails with a structured notice instead of hanging.
|
|
307
|
+
if (input.eventsPath) built.env.PI_CREW_STATE_ROOT = path.dirname(input.eventsPath);
|
|
268
308
|
// Phase 0 inter-pi broker: inject socket path + token (control-namespace keys,
|
|
269
309
|
// safe under assertOnlyControlEnvKeys). Only when the parent broker issued
|
|
270
310
|
// credentials for this run — i.e. the broker is enabled AND this run is
|
|
@@ -275,6 +315,17 @@ export function prepareSpawnContext(
|
|
|
275
315
|
built.env.PI_CREW_BROKER_TOKEN = input.brokerSpawn.token;
|
|
276
316
|
// runId/taskId are threaded unconditionally above (I5); nothing to add here.
|
|
277
317
|
}
|
|
318
|
+
// WP-9 (R9): the worker self-reporting channel env is UNCONDITIONAL —
|
|
319
|
+
// previously scratchpad-gated, which excluded read-only roles by design
|
|
320
|
+
// (S-6) and left them no reporting path. The bounded channel module
|
|
321
|
+
// (worker-events-channel.ts) rate-limits + schema-tags worker.* appends.
|
|
322
|
+
// Control-namespace keys → assertOnlyControlEnvKeys. PI_CREW_TASK_ID is
|
|
323
|
+
// set here when absent so the channel's events carry their task (the
|
|
324
|
+
// scratchpad block below sets it first when it runs — same value).
|
|
325
|
+
if (input.eventsPath) {
|
|
326
|
+
built.env.PI_CREW_EVENTS_PATH = input.eventsPath;
|
|
327
|
+
if (input.agentId && !built.env.PI_CREW_TASK_ID) built.env.PI_CREW_TASK_ID = input.agentId;
|
|
328
|
+
}
|
|
278
329
|
// Phase 1 scratchpad: opt in the persistent Bun-free JS evaluator (execute
|
|
279
330
|
// tool) for this worker. Gated by role/agent (S-6 read-only roles never;
|
|
280
331
|
// F6 agent.scratchpad===false kills). All keys are PI_CREW_* control vars →
|
|
@@ -290,12 +341,7 @@ export function prepareSpawnContext(
|
|
|
290
341
|
// artifactsRoot after F4/S-1).
|
|
291
342
|
built.env.PI_CREW_ARTIFACTS_ROOT = input.artifactsRoot;
|
|
292
343
|
}
|
|
293
|
-
//
|
|
294
|
-
// handler can append fire-and-forget metric events (scratchpad.cell /
|
|
295
|
-
// scratchpad.restored). Optional — absent in non-team contexts.
|
|
296
|
-
if (input.eventsPath) {
|
|
297
|
-
built.env.PI_CREW_EVENTS_PATH = input.eventsPath;
|
|
298
|
-
} // F4/S-1: the RAW (unredacted) snapshot must NEVER land in artifactsRoot —
|
|
344
|
+
// F4/S-1: the RAW (unredacted) snapshot must NEVER land in artifactsRoot —
|
|
299
345
|
// point it at a temp dir; the worker reads it then writeArtifact()
|
|
300
346
|
// (redact+atomic) is the ONLY writer into artifactsRoot.
|
|
301
347
|
// R3-1: built.tempDir is only created by buildPiWorkerArgs when the agent
|
|
@@ -11,7 +11,13 @@
|
|
|
11
11
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
12
12
|
import { extractText } from "../output/pi-json-output.ts";
|
|
13
13
|
import type { ChildPiRunInput } from "./child-pi.ts";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
MAX_ASSISTANT_TEXT_CHARS,
|
|
16
|
+
MAX_LINE_BUFFER_BYTES,
|
|
17
|
+
MAX_THINKING_CHARS,
|
|
18
|
+
MAX_TOOL_INPUT_CHARS,
|
|
19
|
+
MAX_TOOL_RESULT_CHARS,
|
|
20
|
+
} from "./child-pi-constants.ts";
|
|
15
21
|
import { appendTranscript, compactString, compactValue, flushPendingTranscriptWrites } from "./child-pi-transcript.ts";
|
|
16
22
|
|
|
17
23
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
@@ -38,6 +44,20 @@ function compactContentPart(part: unknown): unknown | undefined {
|
|
|
38
44
|
name: record.name,
|
|
39
45
|
input: compactValue(typeof record.input === "string" ? compactString(record.input, MAX_TOOL_INPUT_CHARS) : record.input),
|
|
40
46
|
};
|
|
47
|
+
// Reasoning is display-only, but the agent view pane renders the FULL
|
|
48
|
+
// assistant message (AssistantMessageComponent) — without the thinking
|
|
49
|
+
// parts the pane would look sparse next to a real pi session. Keep them
|
|
50
|
+
// capped so a long-thinking model cannot balloon the event log.
|
|
51
|
+
if (record.type === "thinking")
|
|
52
|
+
return {
|
|
53
|
+
type: "thinking",
|
|
54
|
+
thinking:
|
|
55
|
+
typeof record.thinking === "string"
|
|
56
|
+
? compactString(record.thinking, MAX_THINKING_CHARS, {
|
|
57
|
+
preserveImportant: false,
|
|
58
|
+
})
|
|
59
|
+
: "",
|
|
60
|
+
};
|
|
41
61
|
if (record.type === "toolResult")
|
|
42
62
|
return {
|
|
43
63
|
type: "toolResult",
|