pi-crew 0.11.0 → 0.11.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 +155 -9
- package/README.md +161 -1037
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +744 -90644
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +1 -3
- package/schema.json +39 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
- package/skills/real-test-pi-crew/SKILL.md +371 -34
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -2
- package/src/config/config.ts +47 -13
- package/src/config/defaults.ts +0 -1
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -5
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +23 -0
- package/src/extension/crew-vibes/config.ts +0 -21
- package/src/extension/crew-vibes/index.ts +0 -2
- package/src/extension/crew-vibes/render.ts +1 -50
- package/src/extension/help.ts +21 -12
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/register.ts +7 -2
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +36 -19
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +23 -1
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +35 -0
- package/src/extension/registration/lifecycle-handlers.ts +91 -30
- package/src/extension/registration/lifecycle.ts +75 -10
- package/src/extension/registration/observability.ts +98 -35
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/subagent-tools.ts +16 -6
- package/src/extension/registration/team-tool.ts +10 -3
- package/src/extension/registration/terminal-status-wiring.ts +172 -0
- package/src/extension/registration/viewers.ts +6 -0
- package/src/extension/registration/wire-cross-extension.ts +28 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal.ts +4 -1
- package/src/extension/team-tool/handle-settings.ts +33 -4
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +87 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +17 -11
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +5 -1
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/recovery/crash-recovery.ts +25 -1
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/task-display.ts +1 -1
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +142 -45
- package/src/runtime/task-runner.ts +21 -1
- package/src/runtime/team-runner.ts +38 -1
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +18 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +140 -15
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +112 -22
- package/src/state/types.ts +4 -0
- package/src/ui/adaptive-card.ts +65 -0
- package/src/ui/agents-jobs-browser.ts +70 -64
- package/src/ui/card-colors.ts +36 -7
- package/src/ui/dashboard-panes/agents-pane.ts +55 -14
- package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
- package/src/ui/dashboard-panes/health-pane.ts +7 -5
- package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
- package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
- package/src/ui/dashboard-panes/pane-theme.ts +21 -0
- package/src/ui/dashboard-panes/plan-pane.ts +63 -30
- package/src/ui/dashboard-panes/progress-pane.ts +3 -2
- package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
- package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
- package/src/ui/dwf-phase-display.ts +3 -20
- package/src/ui/format-helpers.ts +22 -0
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/inline-panel/crew-editor.ts +13 -3
- package/src/ui/inline-panel/index.ts +60 -4
- package/src/ui/keybinding-map.ts +251 -35
- package/src/ui/live-conversation-overlay.ts +180 -47
- package/src/ui/live-run-sidebar.ts +134 -55
- package/src/ui/mascot.ts +32 -16
- package/src/ui/overlays/agent-picker-overlay.ts +81 -26
- package/src/ui/overlays/confirm-overlay.ts +55 -29
- package/src/ui/overlays/help-overlay.ts +108 -53
- package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
- package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
- package/src/ui/powerbar-publisher.ts +0 -1
- package/src/ui/rail.ts +333 -0
- package/src/ui/run-dashboard.ts +193 -79
- package/src/ui/run-snapshot-cache.ts +18 -1
- package/src/ui/settings-overlay.ts +81 -39
- package/src/ui/spinner.ts +26 -2
- package/src/ui/terminal-status.ts +7 -1
- package/src/ui/theme-adapter.ts +0 -45
- package/src/ui/theme-discovery.ts +12 -6
- package/src/ui/tool-progress-formatter.ts +128 -9
- package/src/ui/tool-renderers/brief-mode.ts +10 -67
- package/src/ui/tool-renderers/index.ts +374 -523
- package/src/ui/transcript-viewer.ts +30 -12
- package/src/ui/widget/index.ts +32 -52
- package/src/ui/widget/task-list.ts +64 -32
- package/src/ui/widget/widget-formatters.ts +3 -402
- package/src/ui/widget/widget-model.ts +28 -7
- package/src/ui/widget/widget-renderer.ts +201 -128
- package/src/ui/widget/widget-types.ts +0 -2
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/utils/visual.ts +0 -4
- package/src/worktree/worktree-manager.ts +206 -26
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
- package/src/extension/crew-vibes/figures.ts +0 -22
- package/src/extension/crew-vibes/font-detect.ts +0 -71
- package/src/ui/dynamic-border.ts +0 -35
- package/src/ui/loaders.ts +0 -6
- package/src/ui/overlay-stack.ts +0 -148
|
@@ -24,6 +24,7 @@ export type ScheduleChangeEvent =
|
|
|
24
24
|
| { type: "removed"; jobId: string; spawnedRunIds?: string[] }
|
|
25
25
|
| { type: "updated"; job: ScheduledJob }
|
|
26
26
|
| { type: "fired"; jobId: string; agentId: string; name: string }
|
|
27
|
+
| { type: "skipped"; jobId: string; reason: string }
|
|
27
28
|
| { type: "error"; jobId: string; error: string };
|
|
28
29
|
|
|
29
30
|
export interface CrewSchedulerOptions {
|
|
@@ -152,19 +153,29 @@ export class CrewScheduler {
|
|
|
152
153
|
private arm(job: ScheduledJob): void {
|
|
153
154
|
if (this.timers.has(job.id)) return;
|
|
154
155
|
if (job.scheduleType === "interval" && job.intervalMs) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
// 2026-09-22 spawn-storm root cause: this branch used
|
|
157
|
+
// setInterval(fire, intervalMs). Node timers are 32-bit — a LEGAL long
|
|
158
|
+
// interval (e.g. 90d = 7,776,000,000ms > 2^31-1) overflows and Node
|
|
159
|
+
// silently sets the delay to 1ms (TimeoutOverflowWarning): the job then
|
|
160
|
+
// fired every millisecond, and each
|
|
161
|
+
// tick dispatched another run (measured live: ~104 garbage runs + 50+
|
|
162
|
+
// node processes from ONE registered 90-day interval job). armCron()
|
|
163
|
+
// already clamps its hops below the same ceiling — the interval branch
|
|
164
|
+
// was missed. Reuse the identical chained-hop treatment: schedule toward
|
|
165
|
+
// now+intervalMs, clamped; cronTick fires on arrival (its
|
|
166
|
+
// advanceCronNextRun is a cron-only no-op here), and fire()'s internal
|
|
167
|
+
// update() → disarm→arm lifecycle re-arms the next interval.
|
|
168
|
+
const targetMs = this.now().getTime() + job.intervalMs;
|
|
169
|
+
this.setCronTimeout(job.id, targetMs);
|
|
158
170
|
} else if (job.scheduleType === "once") {
|
|
159
171
|
const target = new Date(job.schedule).getTime();
|
|
160
172
|
const delay = target - this.now().getTime();
|
|
161
173
|
if (delay > 0) {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
this.timers.set(job.id, t);
|
|
174
|
+
// Same 32-bit ceiling as the interval branch above: a once job armed
|
|
175
|
+
// > 2^31-1 ms out (e.g. "+30d" — a LEGAL relative spec — or a far ISO
|
|
176
|
+
// timestamp) overflowed setTimeout to a 1ms PREMATURE fire. Chained hops
|
|
177
|
+
// instead; cronTick self-disables once-jobs on arrival (below).
|
|
178
|
+
this.setCronTimeout(job.id, target);
|
|
168
179
|
} else {
|
|
169
180
|
this.update(job.id, { enabled: false, lastStatus: "error" });
|
|
170
181
|
this.emit?.({
|
|
@@ -219,6 +230,9 @@ export class CrewScheduler {
|
|
|
219
230
|
// hop that just fired is dead, so no timer doubles up.
|
|
220
231
|
this.fire(jobId);
|
|
221
232
|
this.advanceCronNextRun(jobId);
|
|
233
|
+
// once-semantics (was inline in arm()'s removed setTimeout): consume the
|
|
234
|
+
// job after its single arrival fire so a re-arm cannot fire it again.
|
|
235
|
+
if (job.scheduleType === "once") this.update(jobId, { enabled: false });
|
|
222
236
|
}
|
|
223
237
|
|
|
224
238
|
/** After a cron fire, advance the persisted nextRun to the next occurrence
|
|
@@ -265,6 +279,18 @@ export class CrewScheduler {
|
|
|
265
279
|
const job = this.jobs.get(id);
|
|
266
280
|
if (!job || !this.executor) return;
|
|
267
281
|
if (!job.enabled && !force) return;
|
|
282
|
+
// In-flight guard (2026-09-22, storm follow-up): the executor dispatches
|
|
283
|
+
// asynchronously and resets lastStatus (success/error) only on completion,
|
|
284
|
+
// so `running` marks the in-flight window. Without backpressure here, any
|
|
285
|
+
// interval shorter than the run duration stacks one overlapping run per
|
|
286
|
+
// tick (the 1ms overflow loop was the extreme case; fixed below/above).
|
|
287
|
+
// Skips emit a "skipped" event (visible to pi.events consumers; the toast
|
|
288
|
+
// bridge stays silent — a slow executor + short interval would spam).
|
|
289
|
+
// `force` (run-now) is the explicit escape hatch.
|
|
290
|
+
if (job.lastStatus === "running" && !force) {
|
|
291
|
+
this.emit?.({ type: "skipped", jobId: id, reason: "previous dispatch still in flight" });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
268
294
|
this.update(id, { lastStatus: "running" });
|
|
269
295
|
let agentId: string;
|
|
270
296
|
try {
|
|
@@ -297,10 +323,15 @@ export class CrewScheduler {
|
|
|
297
323
|
normalized: new Date(Date.now() + ms).toISOString(),
|
|
298
324
|
};
|
|
299
325
|
}
|
|
300
|
-
// Interval: 5m
|
|
301
|
-
|
|
326
|
+
// Interval: 5m — "ms" MUST be accepted: handle-schedule builds
|
|
327
|
+
// `${params.interval}ms` from the schema-level numeric `interval` param
|
|
328
|
+
// (ms number), so without an ms unit here every interval schedule died in
|
|
329
|
+
// the parser ("Invalid schedule …") — found live 2026-09-21 when
|
|
330
|
+
// `team action='schedule' interval=3600000` was impossible to satisfy.
|
|
331
|
+
const ivl = trimmed.match(/^(\d+)(ms|s|m|h|d)$/);
|
|
302
332
|
if (ivl) {
|
|
303
|
-
const ms =
|
|
333
|
+
const ms =
|
|
334
|
+
parseInt(ivl[1], 10) * { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[ivl[2] as "ms" | "s" | "m" | "h" | "d"];
|
|
304
335
|
return { type: "interval", intervalMs: ms, normalized: trimmed };
|
|
305
336
|
}
|
|
306
337
|
// ISO timestamp
|
|
@@ -329,13 +360,18 @@ function parseIntervalMs(s: string): number | undefined {
|
|
|
329
360
|
let ms = 0;
|
|
330
361
|
let remaining = s;
|
|
331
362
|
const unitMs: Record<string, number> = {
|
|
363
|
+
// "ms" MUST be first in the regex alternation below and present here:
|
|
364
|
+
// handle-schedule builds `${params.interval}ms` from the numeric interval
|
|
365
|
+
// param, so without an ms unit every interval schedule failed to parse
|
|
366
|
+
// (found live 2026-09-21 — `interval=3600000` was unsatisfiable).
|
|
367
|
+
ms: 1,
|
|
332
368
|
s: 1000,
|
|
333
369
|
m: 60_000,
|
|
334
370
|
h: 3_600_000,
|
|
335
371
|
d: 86_400_000,
|
|
336
372
|
};
|
|
337
373
|
while (remaining.length > 0) {
|
|
338
|
-
const m = remaining.match(/^(\d+)(s|m|h|d)/);
|
|
374
|
+
const m = remaining.match(/^(\d+)(ms|s|m|h|d)/);
|
|
339
375
|
if (!m) return undefined;
|
|
340
376
|
ms += parseInt(m[1], 10) * unitMs[m[2]];
|
|
341
377
|
remaining = remaining.slice(m[0].length);
|
|
@@ -3,11 +3,92 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Adapted from oh-my-pi's `parallel.ts` Semaphore class and
|
|
5
5
|
* `mapWithConcurrencyLimit` implementation. Provides:
|
|
6
|
-
* - Explicit acquire/release Semaphore for concurrency control
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* - Explicit acquire/release Semaphore for concurrency control, with an
|
|
7
|
+
* OPTIONAL AbortSignal on acquire() (RR-014 / F15: a queued waiter settles
|
|
8
|
+
* promptly when its signal fires — it never waits out the current slot
|
|
9
|
+
* holder, and never consumes a slot once cancelled)
|
|
10
|
+
* - Fail-fast on first error (via Promise.race) — mapWithFailFast below
|
|
11
|
+
* - AbortSignal support for graceful cancellation — mapWithFailFast below
|
|
12
|
+
* - Partial results on abort — mapWithFailFast below
|
|
13
|
+
*
|
|
14
|
+
* RR-014 / F15 defect history: acquire() previously took NO AbortSignal, so a
|
|
15
|
+
* cancelled waiter stayed pending until the slot holder released, and then
|
|
16
|
+
* "received" a slot it could never use (verified probe: acquire() returned
|
|
17
|
+
* with signal.aborted === true). The signal-aware acquire below closes that:
|
|
18
|
+
* aborted waiters are eagerly removed from the queue and REJECT with
|
|
19
|
+
* SemaphoreAbortedError; #current never changes for an aborted acquire.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Rejection reason for {@link Semaphore.acquire} when the caller's AbortSignal
|
|
24
|
+
* fires before a slot is granted (RR-014 / F15). Distinguishable via
|
|
25
|
+
* `name === "SemaphoreAbortedError"` — an aborted acquire never bare-resolves.
|
|
26
|
+
*/
|
|
27
|
+
export class SemaphoreAbortedError extends Error {
|
|
28
|
+
constructor() {
|
|
29
|
+
super("Semaphore acquire aborted: the caller's AbortSignal fired before a slot was granted");
|
|
30
|
+
this.name = "SemaphoreAbortedError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Lifecycle outcome of a queued waiter. */
|
|
35
|
+
type WaiterOutcome = "granted" | "aborted";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Queue entry WITH identity (RR-014 / F15). The pre-fix queue stored bare
|
|
39
|
+
* resolve functions: an aborted waiter could not be referenced or removed, so
|
|
40
|
+
* it stayed pending until a release() handed it a slot it could never use.
|
|
41
|
+
* A record with an idempotent settle() can detach itself from its abort
|
|
42
|
+
* listener exactly once no matter how the abort↔handoff race resolves.
|
|
43
|
+
*/
|
|
44
|
+
interface Waiter {
|
|
45
|
+
/** Resolves on "granted"; rejects with SemaphoreAbortedError on "aborted". */
|
|
46
|
+
readonly promise: Promise<void>;
|
|
47
|
+
/** Whether settle() already ran (first call wins). */
|
|
48
|
+
readonly settled: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Settle this waiter exactly once and detach its abort listener.
|
|
51
|
+
* - "granted" → resolve: the waiter now OWNS a slot and must release().
|
|
52
|
+
* - "aborted" → reject: the waiter never owned a slot.
|
|
53
|
+
*/
|
|
54
|
+
settle(kind: WaiterOutcome): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a waiter and attach its abort listener on the CALLER'S signal
|
|
59
|
+
* (never on a derived signal — a listener on AbortSignal.any() cannot be
|
|
60
|
+
* removed from the original). `onAbort` runs synchronously when the signal
|
|
61
|
+
* fires; the semaphore uses it to remove the waiter from the queue eagerly.
|
|
10
62
|
*/
|
|
63
|
+
function createWaiter(signal: AbortSignal | undefined, onAbort: () => void): Waiter {
|
|
64
|
+
let resolve!: () => void;
|
|
65
|
+
let reject!: (error: Error) => void;
|
|
66
|
+
const promise = new Promise<void>((res, rej) => {
|
|
67
|
+
resolve = res;
|
|
68
|
+
reject = rej;
|
|
69
|
+
});
|
|
70
|
+
let isSettled = false;
|
|
71
|
+
let detachListener: (() => void) | undefined;
|
|
72
|
+
if (signal) {
|
|
73
|
+
const listener = () => onAbort();
|
|
74
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
75
|
+
detachListener = () => signal.removeEventListener("abort", listener);
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
promise,
|
|
79
|
+
get settled() {
|
|
80
|
+
return isSettled;
|
|
81
|
+
},
|
|
82
|
+
settle(kind: WaiterOutcome) {
|
|
83
|
+
if (isSettled) return;
|
|
84
|
+
isSettled = true;
|
|
85
|
+
detachListener?.();
|
|
86
|
+
detachListener = undefined;
|
|
87
|
+
if (kind === "granted") resolve();
|
|
88
|
+
else reject(new SemaphoreAbortedError());
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
11
92
|
|
|
12
93
|
/**
|
|
13
94
|
* Simple counting semaphore for limiting concurrency across independently-scheduled async work.
|
|
@@ -15,7 +96,7 @@
|
|
|
15
96
|
export class Semaphore {
|
|
16
97
|
#max: number;
|
|
17
98
|
#current = 0;
|
|
18
|
-
#queue:
|
|
99
|
+
#queue: Waiter[] = [];
|
|
19
100
|
// FIX (Round 15): Cap the waiter queue to prevent unbounded memory growth
|
|
20
101
|
// if the semaphore is held for a long period and many tasks accumulate.
|
|
21
102
|
static readonly MAX_QUEUE = 10_000;
|
|
@@ -24,9 +105,44 @@ export class Semaphore {
|
|
|
24
105
|
this.#max = Math.max(1, max);
|
|
25
106
|
}
|
|
26
107
|
|
|
27
|
-
|
|
108
|
+
/**
|
|
109
|
+
* Acquire a slot, optionally abortable (RR-014 / F15).
|
|
110
|
+
*
|
|
111
|
+
* - No signal → unchanged semantics: blocks until a slot frees.
|
|
112
|
+
* - Signal already aborted at entry → rejects immediately (fail-closed):
|
|
113
|
+
* no slot taken, nothing enqueued. Abort listeners never re-fire for an
|
|
114
|
+
* already-aborted signal, so such a waiter could never be removed later.
|
|
115
|
+
* - Signal fires while WAITING → the waiter is removed from the queue
|
|
116
|
+
* EAGERLY (synchronously inside the abort listener) and rejects with
|
|
117
|
+
* SemaphoreAbortedError. It never consumes a slot: #current is untouched.
|
|
118
|
+
*
|
|
119
|
+
* Abort↔handoff race resolves to exactly one consistent state because
|
|
120
|
+
* every #queue/#current mutation is synchronous (no await between them)
|
|
121
|
+
* and settle() is idempotent (first call wins, and it detaches the abort
|
|
122
|
+
* listener):
|
|
123
|
+
* - abort wins → the waiter was spliced out of the queue, so release()
|
|
124
|
+
* cannot hand it a slot; the freed slot reaches the next ALIVE waiter
|
|
125
|
+
* (or decrements #current). Capacity intact.
|
|
126
|
+
* - grant wins → settle("granted") detached the listener, so a later
|
|
127
|
+
* abort is a no-op at semaphore level; the caller observes
|
|
128
|
+
* signal.aborted and releases normally (runChildPi's pre-spawn guard
|
|
129
|
+
* returns kind "aborted" without spawning, so withWorkerSlot's finally
|
|
130
|
+
* releases the slot within a microtask of the grant).
|
|
131
|
+
*/
|
|
132
|
+
async acquire(signal?: AbortSignal): Promise<void> {
|
|
133
|
+
if (signal?.aborted) {
|
|
134
|
+
return Promise.reject(new SemaphoreAbortedError());
|
|
135
|
+
}
|
|
28
136
|
if (this.#current < this.#max) {
|
|
29
137
|
this.#current++;
|
|
138
|
+
// Re-check the abort↔grant window before resolving. The path above
|
|
139
|
+
// is synchronous so the flag cannot flip today; this guards against
|
|
140
|
+
// a future await being inserted here (belt-and-braces — concurrency
|
|
141
|
+
// primitive). Never resolve a granted acquire for an aborted signal.
|
|
142
|
+
if (signal?.aborted) {
|
|
143
|
+
this.#current--;
|
|
144
|
+
return Promise.reject(new SemaphoreAbortedError());
|
|
145
|
+
}
|
|
30
146
|
return;
|
|
31
147
|
}
|
|
32
148
|
// FIX (Round 15): Reject when the waiter queue is full. The previous
|
|
@@ -38,23 +154,35 @@ export class Semaphore {
|
|
|
38
154
|
new Error(`Semaphore queue full: ${this.#queue.length} waiters (max ${Semaphore.MAX_QUEUE}); cannot acquire slot`),
|
|
39
155
|
);
|
|
40
156
|
}
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
157
|
+
const waiter = createWaiter(signal, () => {
|
|
158
|
+
// Eager removal, synchronously inside the abort listener: the waiter
|
|
159
|
+
// leaves the queue BEFORE settling. A release() that already
|
|
160
|
+
// shift()ed it (grant won the race) finds settle() idempotent; a
|
|
161
|
+
// release() that has not cannot hand a slot to a dead waiter.
|
|
162
|
+
// #current is untouched — this waiter never owned a slot.
|
|
163
|
+
const index = this.#queue.indexOf(waiter);
|
|
164
|
+
if (index >= 0) this.#queue.splice(index, 1);
|
|
165
|
+
waiter.settle("aborted");
|
|
166
|
+
});
|
|
167
|
+
this.#queue.push(waiter);
|
|
168
|
+
return waiter.promise;
|
|
50
169
|
}
|
|
51
170
|
|
|
52
171
|
release(): void {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
172
|
+
// Hand the freed slot to the first ALIVE waiter (FIFO). A settled entry
|
|
173
|
+
// still sitting in the queue is impossible with eager removal but is
|
|
174
|
+
// skipped defensively — a slot must never burn on a dead waiter. Handoff
|
|
175
|
+
// TRANSFERS ownership: #current is NOT decremented; only when no alive
|
|
176
|
+
// waiter remains does it decrement.
|
|
177
|
+
while (true) {
|
|
178
|
+
const next = this.#queue.shift();
|
|
179
|
+
if (next === undefined) {
|
|
180
|
+
if (this.#current > 0) this.#current--;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (next.settled) continue;
|
|
184
|
+
next.settle("granted");
|
|
185
|
+
return;
|
|
58
186
|
}
|
|
59
187
|
// Guard: over-release is a no-op to prevent #current going negative
|
|
60
188
|
}
|
|
@@ -125,7 +125,7 @@ attempt N+1 worker (scratchpad-lifecycle.ts)
|
|
|
125
125
|
is a Phase 2.5/3 hardening if artifacts ever land in a shared location.
|
|
126
126
|
|
|
127
127
|
> **REMOVED (2026-08-12):** `src/runtime/scratchpad/snapshot-hmac.ts` and its
|
|
128
|
-
> test were deleted. Decision (P3 of `docs/rlm-fixes-implementation-plan.md`):
|
|
128
|
+
> test were deleted. Decision (P3 of `docs/archive/rlm-fixes-implementation-plan.md`):
|
|
129
129
|
> the HMAC threat is double-conditional — it only materializes when scratchpad
|
|
130
130
|
> has adoption (>0 cells; today: 0/83 runs) AND snapshots move to a
|
|
131
131
|
> shared/networked store (today: same-uid dev-machine). Hardening a
|
|
@@ -46,7 +46,7 @@ export interface GuestToHost {
|
|
|
46
46
|
};
|
|
47
47
|
pong: { type: "pong"; id: string };
|
|
48
48
|
host_request: {
|
|
49
|
-
// RESERVED for future host bridge (rlm-deep-review-2026-08-12.md §5.2F /
|
|
49
|
+
// RESERVED for future host bridge (docs/archive/rlm-deep-review-2026-08-12.md §5.2F /
|
|
50
50
|
// J2): guest-side cells would request host services (tools.read,
|
|
51
51
|
// tools.grep) so data enters the namespace WITHOUT crossing the
|
|
52
52
|
// transcript — pi-rlm's core token-saving value proposition. Declared
|
|
@@ -112,7 +112,7 @@ function readSettingsFile(filePath: string): CrewSettings {
|
|
|
112
112
|
* API — never this function. Kept exported for back-compat reads/tests;
|
|
113
113
|
* adding a new consumer that writes into loadedConfig re-introduces the
|
|
114
114
|
* fixed P1 bypass (see docs/decisions/2026-08-15-schema-driven-sanitize.md
|
|
115
|
-
* and the Wave 2B execution log in docs/refactor-plan.md).
|
|
115
|
+
* and the Wave 2B execution log in docs/archive/refactor-plan.md).
|
|
116
116
|
*/
|
|
117
117
|
export function loadCrewSettings(cwd: string = process.cwd(), globalFile: string = globalPath()): CrewSettings {
|
|
118
118
|
return {
|
|
@@ -7,6 +7,7 @@ import { packageRoot } from "../utils/paths.ts";
|
|
|
7
7
|
import { isSafePathId, resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
8
8
|
import type { WorkflowStep } from "../workflows/workflow-config.ts";
|
|
9
9
|
import { CONFIDENCE_THRESHOLDS, getWeightedSkillsForRole, registerSkillEffectivenessHooks } from "./skill-effectiveness.ts";
|
|
10
|
+
import { promptSkillMode } from "./task-runner/prompt-builder.ts";
|
|
10
11
|
|
|
11
12
|
const PACKAGE_SKILLS_DIR = path.join(packageRoot(), "skills");
|
|
12
13
|
|
|
@@ -324,6 +325,10 @@ export function renderSkillInstructions(
|
|
|
324
325
|
const names = allNames.slice(0, MAX_SELECTED_SKILLS);
|
|
325
326
|
const overflowCount = Math.max(0, allNames.length - names.length);
|
|
326
327
|
if (names.length === 0) return { names, paths: [], block: "" };
|
|
328
|
+
// SR-02 phase 2: "index" mode (default) injects compact entries instead of
|
|
329
|
+
// full skill bodies — skills were 40-50% of measured worker prompts while a
|
|
330
|
+
// Path pointer + read tool gives the worker the SAME information on demand.
|
|
331
|
+
const mode = promptSkillMode();
|
|
327
332
|
const sections: string[] = [];
|
|
328
333
|
const skillPaths: string[] = [];
|
|
329
334
|
let total = 0;
|
|
@@ -379,6 +384,23 @@ export function renderSkillInstructions(
|
|
|
379
384
|
]
|
|
380
385
|
.filter(Boolean)
|
|
381
386
|
.join("\n");
|
|
387
|
+
if (mode === "index") {
|
|
388
|
+
// Index entry: the description (frontmatter) says WHEN the skill
|
|
389
|
+
// applies; the Path says WHERE to read it. The worker loads the full
|
|
390
|
+
// SKILL.md only when the task matches — same information, on demand.
|
|
391
|
+
const entry = [
|
|
392
|
+
`## ${safeName}`,
|
|
393
|
+
description ? `Description: ${description}${confidenceNote}` : undefined,
|
|
394
|
+
`Source: ${source}`,
|
|
395
|
+
`Path: ${path.dirname(loaded.path)}`,
|
|
396
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: ${Path} refers to the Path field printed above, not a JS interpolation
|
|
397
|
+
"When this skill matches your task, FIRST read ${Path}/SKILL.md and follow it.",
|
|
398
|
+
]
|
|
399
|
+
.filter(Boolean)
|
|
400
|
+
.join("\n");
|
|
401
|
+
if (!pushSection(entry)) omittedCount += 1;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
382
404
|
const rawContent = loaded.compacted;
|
|
383
405
|
// Wrap skill content with provenance markers to help LLMs distinguish skill instructions
|
|
384
406
|
const wrappedContent = `<!-- skill: ${safeName} -->\n${rawContent}\n<!-- end-skill: ${safeName} -->`;
|
|
@@ -1,17 +1,29 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
+
import { getCrewEnv } from "../config/env-vars.ts";
|
|
4
5
|
import { errors } from "../errors.ts";
|
|
5
6
|
import { atomicWriteFile, atomicWriteJson } from "../state/atomic-write.ts";
|
|
6
7
|
import { getCurrentPlanRecord } from "../state/stores/plan-store.ts";
|
|
7
8
|
import { loadManifestWithRecovery, loadTasksWithRecovery, saveRunManifest } from "../state/stores/state-store.ts";
|
|
8
9
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
9
10
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
11
|
+
import { findRunStateDir } from "../utils/paths.ts";
|
|
10
12
|
import { recordFromTask, upsertCrewAgent } from "./crew-agent-records.ts";
|
|
11
13
|
import { checkProcessLiveness } from "./process-status.ts";
|
|
12
14
|
|
|
13
15
|
/** Age threshold for orphaned temp directory cleanup: 1 hour. */
|
|
14
16
|
const ORPHAN_TEMP_DIR_AGE_THRESHOLD_MS = 60 * 60 * 1000;
|
|
17
|
+
/** A `.cleanup-in-progress` sentinel older than this is abandoned — its owner
|
|
18
|
+
* died mid-cleanup (session kill between sentinel-create and dir-delete) and
|
|
19
|
+
* without reclamation the workspace is frozen out of the cleanup cycle
|
|
20
|
+
* FOREVER (EEXIST → skip, no TTL). Live 2026-09-26: 42 frozen workspaces. */
|
|
21
|
+
const SENTINEL_STALE_RECLAIM_MS = 10 * 60 * 1000;
|
|
22
|
+
/** Assumed production cadence of the temp-workspace reconcile tick (60s —
|
|
23
|
+
* see observability.ts tempReconcileTimer). Only used to derive the
|
|
24
|
+
* stateless round-robin batch index; a different real cadence still cycles
|
|
25
|
+
* through every batch, just with different pacing. */
|
|
26
|
+
const ORPHAN_RECONCILE_TICK_MS = 60_000;
|
|
15
27
|
/** Defense-in-depth: cap the number of /tmp/pi-crew-* entries processed per
|
|
16
28
|
* reconcile tick. With a few thousand accumulated dirs, processing them all
|
|
17
29
|
* synchronously can block the main thread for many seconds, causing the
|
|
@@ -284,7 +296,7 @@ function isTaskHeartbeatStale(task: TeamTaskState, now: number): boolean {
|
|
|
284
296
|
const taskPid = task.heartbeat?.pid ?? task.checkpoint?.childPid;
|
|
285
297
|
const pidAlive = taskPid ? checkProcessLiveness(taskPid).alive : false;
|
|
286
298
|
if (taskPid && pidAlive) return false;
|
|
287
|
-
if (
|
|
299
|
+
if (getCrewEnv("PI_CREW_DEBUG_STALE") === "1") {
|
|
288
300
|
// F1 forensic (battery 2026-09-10): sidecar log of every STALE verdict so
|
|
289
301
|
// live repros can show exactly what the reconciler saw (pid present? alive?
|
|
290
302
|
// elapsed?) — the reconciler may run in ANY host process, hence a fixed
|
|
@@ -568,18 +580,38 @@ export function reconcileOrphanedTempWorkspaces(
|
|
|
568
580
|
const scanBatch = options?.scanBatchSize ?? ORPHAN_TEMP_SCAN_BATCH_SIZE;
|
|
569
581
|
const candidates = entries
|
|
570
582
|
.filter((e) => e.isDirectory() && e.name.startsWith("pi-crew-"))
|
|
571
|
-
.sort((a, b) => a.name.localeCompare(b.name))
|
|
572
|
-
|
|
573
|
-
|
|
583
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
584
|
+
// STATELESS ROUND-ROBIN across ticks (Bug B, live 2026-09-26): the old
|
|
585
|
+
// `slice(0, scanBatch)` visited only the alphabetically-first batch, so a
|
|
586
|
+
// cluster of uncleanable dirs (frozen sentinels / fresh sentinels /
|
|
587
|
+
// waiting runs) starved everything behind it — 42 frozen sentinels in the
|
|
588
|
+
// `agent-stale-wakeup-test-*` cluster (alphabetically first) blocked ~3.4k
|
|
589
|
+
// dirs from EVER being scanned. Each tick now takes the NEXT slice,
|
|
590
|
+
// derived deterministically from `now` (production cadence is 60s/tick —
|
|
591
|
+
// see observability.ts tempReconcileTimer), so every batch is visited
|
|
592
|
+
// within `batches` ticks without any persisted state. Deterministic under
|
|
593
|
+
// the injected `now` that tests already use.
|
|
594
|
+
const batches = Math.max(1, Math.ceil(candidates.length / scanBatch));
|
|
595
|
+
const batchIdx = Math.floor(now / ORPHAN_RECONCILE_TICK_MS) % batches;
|
|
596
|
+
const selected = candidates.slice(batchIdx * scanBatch, batchIdx * scanBatch + scanBatch);
|
|
597
|
+
for (const entry of selected) {
|
|
574
598
|
if (!entry.isDirectory() || !entry.name.startsWith("pi-crew-")) continue;
|
|
575
599
|
const workspaceDir = path.join(tmpDir, entry.name);
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
600
|
+
// RR-020 Fix 3: accept BOTH supported run-state layouts — `<dir>/.crew/
|
|
601
|
+
// state/runs` and the `.pi`-based `<dir>/.pi/teams/state/runs`. Only the
|
|
602
|
+
// `.crew` layout used to be seen here, so a temp workspace with live
|
|
603
|
+
// `.pi/teams` run state was invisible to the reconciler (and then
|
|
604
|
+
// deleted as debris by the legacy cleanup sweep).
|
|
605
|
+
const stateRunsDir = findRunStateDir(workspaceDir);
|
|
606
|
+
if (!stateRunsDir) continue;
|
|
580
607
|
let hasRunning = false;
|
|
581
608
|
try {
|
|
582
|
-
|
|
609
|
+
// Cold-verify F-2: Dirent.isDirectory() is FALSE for a symlink-to-dir
|
|
610
|
+
// (withFileTypes does not follow), so a planted `runs/<runId>` symlink
|
|
611
|
+
// is skipped instead of being read/written out-of-tree.
|
|
612
|
+
for (const entry of fs.readdirSync(stateRunsDir, { withFileTypes: true })) {
|
|
613
|
+
if (!entry.isDirectory()) continue;
|
|
614
|
+
const runDir = entry.name;
|
|
583
615
|
const manifestPath = path.join(stateRunsDir, runDir, "manifest.json");
|
|
584
616
|
const tasksPath = path.join(stateRunsDir, runDir, "tasks.json");
|
|
585
617
|
if (!fs.existsSync(manifestPath) || !fs.existsSync(tasksPath)) continue;
|
|
@@ -700,14 +732,50 @@ export function reconcileOrphanedTempWorkspaces(
|
|
|
700
732
|
fs.closeSync(sentinelFd);
|
|
701
733
|
atomicWriteFile(sentinelPath, JSON.stringify({ startedAt: now }));
|
|
702
734
|
} catch {
|
|
703
|
-
// Sentinel already exists
|
|
704
|
-
|
|
735
|
+
// Sentinel already exists. Either another cleanup is genuinely in
|
|
736
|
+
// progress (it holds the sentinel for seconds at most) OR a previous
|
|
737
|
+
// process died mid-cleanup and ABANDONED it — the frozen-workspace
|
|
738
|
+
// bug (no TTL on the skip). Reclaim sentinels older than
|
|
739
|
+
// SENTINEL_STALE_RECLAIM_MS and retry the exclusive create once;
|
|
740
|
+
// losing the retry race means someone else reclaimed first — skip.
|
|
741
|
+
let sentinelAgeMs = Number.NaN;
|
|
742
|
+
try {
|
|
743
|
+
sentinelAgeMs = now - fs.statSync(sentinelPath).mtimeMs;
|
|
744
|
+
} catch {
|
|
745
|
+
/* sentinel vanished — retry create below */
|
|
746
|
+
}
|
|
747
|
+
if (Number.isFinite(sentinelAgeMs) && sentinelAgeMs <= SENTINEL_STALE_RECLAIM_MS) {
|
|
748
|
+
canCleanup = false; // fresh sentinel — a live cleanup owns it
|
|
749
|
+
} else {
|
|
750
|
+
try {
|
|
751
|
+
fs.unlinkSync(sentinelPath); // stale/absent — reclaim
|
|
752
|
+
} catch {
|
|
753
|
+
/* already gone */
|
|
754
|
+
}
|
|
755
|
+
try {
|
|
756
|
+
const retryFd = fs.openSync(sentinelPath, "wx");
|
|
757
|
+
fs.closeSync(retryFd);
|
|
758
|
+
atomicWriteFile(sentinelPath, JSON.stringify({ startedAt: now, reclaimedFromStale: true }));
|
|
759
|
+
} catch {
|
|
760
|
+
canCleanup = false; // lost the reclaim race — skip
|
|
761
|
+
}
|
|
762
|
+
}
|
|
705
763
|
}
|
|
706
764
|
}
|
|
707
765
|
if (canCleanup) {
|
|
708
766
|
if (fs.existsSync(stateRunsDir)) {
|
|
709
767
|
try {
|
|
710
|
-
|
|
768
|
+
// Cold-verify round 5 (E1): a string readdir FOLLOWS a symlinked
|
|
769
|
+
// `<runId>` entry, and loadManifestWithRecovery on a corrupt manifest
|
|
770
|
+
// then QUARANTINES it — renameSync resolves through the symlinked
|
|
771
|
+
// dir and renamed a file OUTSIDE the scanned tree (reproduced even
|
|
772
|
+
// with cleanupOrphanedTempDirs:false, fired by the production
|
|
773
|
+
// tempReconcileTimer every session). Dirent.isDirectory() is false
|
|
774
|
+
// for a symlink-to-dir, so planted entries are skipped. Deletion
|
|
775
|
+
// itself was already safe (rmSync unlinks symlinks, never follows).
|
|
776
|
+
for (const entry of fs.readdirSync(stateRunsDir, { withFileTypes: true })) {
|
|
777
|
+
if (!entry.isDirectory()) continue;
|
|
778
|
+
const runDir = entry.name;
|
|
711
779
|
const manifestPath = path.join(stateRunsDir, runDir, "manifest.json");
|
|
712
780
|
if (!fs.existsSync(manifestPath)) continue;
|
|
713
781
|
const manifest = loadManifestWithRecovery(manifestPath, runDir);
|
|
@@ -733,7 +801,11 @@ export function reconcileOrphanedTempWorkspaces(
|
|
|
733
801
|
let stillClean = true;
|
|
734
802
|
if (fs.existsSync(stateRunsDir)) {
|
|
735
803
|
try {
|
|
736
|
-
|
|
804
|
+
// Cold-verify round 5 (E1): same Dirent guard as the first gate —
|
|
805
|
+
// quarantine-rename through a symlinked `<runId>` wrote outside.
|
|
806
|
+
for (const entry of fs.readdirSync(stateRunsDir, { withFileTypes: true })) {
|
|
807
|
+
if (!entry.isDirectory()) continue;
|
|
808
|
+
const runDir = entry.name;
|
|
737
809
|
const manifestPath = path.join(stateRunsDir, runDir, "manifest.json");
|
|
738
810
|
if (!fs.existsSync(manifestPath)) continue;
|
|
739
811
|
const manifest = loadManifestWithRecovery(manifestPath, runDir);
|
|
@@ -47,6 +47,6 @@ export function formatTaskGraphLines(tasks: TeamTaskState[]): string[] {
|
|
|
47
47
|
? "⚠"
|
|
48
48
|
: "◦";
|
|
49
49
|
const wait = waitingReason(task, tasks);
|
|
50
|
-
return `- ${icon} ${task.id} [${task.status}] ${task.role}
|
|
50
|
+
return `- ${icon} ${task.id} [${task.status}] ${task.role}▸${task.agent}${wait && wait !== "ready" ? ` (${wait})` : ""}`;
|
|
51
51
|
});
|
|
52
52
|
}
|
|
@@ -38,7 +38,7 @@ import { buildTaskPacket } from "../task-packet.ts";
|
|
|
38
38
|
// this module at runtime; we only need the TaskRunnerInput type here).
|
|
39
39
|
import type { TaskRunnerInput } from "../task-runner.ts";
|
|
40
40
|
import { DEFAULT_YIELD_CONFIG } from "../yield-handler.ts";
|
|
41
|
-
import { coordinationBridgeInstructions, renderTaskPrompt } from "./prompt-builder.ts";
|
|
41
|
+
import { coordinationBridgeInstructions, estimateTokens, promptBreakdownEnabled, renderTaskPrompt } from "./prompt-builder.ts";
|
|
42
42
|
import { checkpointTask, persistSingleTaskUpdate, updateTask } from "./state-helpers.ts";
|
|
43
43
|
|
|
44
44
|
/** The stream bridge handle created by registerStreamBridge. */
|
|
@@ -324,7 +324,7 @@ export async function prepareTaskExecutionContext(
|
|
|
324
324
|
}
|
|
325
325
|
}
|
|
326
326
|
|
|
327
|
-
const promptResult = await renderTaskPrompt(manifest, input.step, task, input.agent, skillBlock);
|
|
327
|
+
const promptResult = await renderTaskPrompt(manifest, input.step, task, input.agent, skillBlock, undefined, skillNames ?? []);
|
|
328
328
|
let prompt = promptResult.full;
|
|
329
329
|
|
|
330
330
|
// Inject deterministic pre-step output into prompt
|
|
@@ -340,6 +340,30 @@ export async function prepareTaskExecutionContext(
|
|
|
340
340
|
content: `${prompt}\n`,
|
|
341
341
|
producer: task.id,
|
|
342
342
|
});
|
|
343
|
+
// SR-02 phase 1: per-section breakdown artifact (opt-in via
|
|
344
|
+
// PI_CREW_PROMPT_BREAKDOWN=1). Adds the SYSTEM-side pieces the prompt
|
|
345
|
+
// builder cannot see (agent definition, skills, pre-step output).
|
|
346
|
+
if (promptBreakdownEnabled()) {
|
|
347
|
+
const sections: Record<string, number> = {
|
|
348
|
+
"system.agentDefinition": input.agent?.systemPrompt?.length ?? 0,
|
|
349
|
+
...(promptResult.sections ?? {}),
|
|
350
|
+
"dynamic.preStepOutput": preStepOutput?.length ?? 0,
|
|
351
|
+
};
|
|
352
|
+
writeArtifact(manifest.artifactsRoot, {
|
|
353
|
+
kind: "metadata",
|
|
354
|
+
relativePath: `metadata/${task.id}.prompt-breakdown.json`,
|
|
355
|
+
content: `${JSON.stringify(
|
|
356
|
+
Object.fromEntries(
|
|
357
|
+
Object.entries(sections)
|
|
358
|
+
.filter(([, chars]) => chars > 0)
|
|
359
|
+
.map(([name, chars]) => [name, { chars, estTokens: estimateTokens(chars) }]),
|
|
360
|
+
),
|
|
361
|
+
null,
|
|
362
|
+
2,
|
|
363
|
+
)}\n`,
|
|
364
|
+
producer: "prompt-breakdown",
|
|
365
|
+
});
|
|
366
|
+
}
|
|
343
367
|
|
|
344
368
|
const collectedJsonEvents: Record<string, unknown>[] | undefined = collectYieldEvents ? [] : undefined;
|
|
345
369
|
|