pi-crew 0.11.21 → 0.11.23
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 +41 -0
- package/dist/index.mjs +542 -531
- package/package.json +2 -1
- package/src/errors.ts +14 -1
- package/src/extension/async-notifier.ts +42 -17
- package/src/extension/cross-extension-rpc.ts +42 -5
- package/src/extension/notification-router.ts +15 -2
- package/src/extension/registration/lifecycle-handlers.ts +36 -13
- package/src/extension/registration/runtime-cleanup.ts +17 -0
- package/src/extension/registration/wire-cross-extension.ts +26 -0
- package/src/extension/rpc-hmac.ts +53 -2
- package/src/extension/team-tool/cancel.ts +34 -21
- package/src/extension/team-tool/dispatch/index.ts +6 -5
- package/src/extension/team-tool/dispatch/status.ts +42 -2
- package/src/extension/team-tool/respond.ts +8 -6
- package/src/extension/team-tool/run.ts +4 -4
- package/src/extension/team-tool.ts +24 -2
- package/src/prompt/inbox-poll.ts +77 -25
- package/src/prompt/message-tool.ts +62 -4
- package/src/prompt/prompt-runtime.ts +82 -32
- package/src/prompt/surface-worker.ts +13 -1
- package/src/runtime/broker/crew-broker-client.ts +74 -16
- package/src/runtime/broker/crew-broker.ts +120 -97
- package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
- package/src/runtime/broker/protocol/request-parsers.ts +68 -0
- package/src/runtime/broker/wait-status-cache.ts +14 -5
- package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
- package/src/runtime/child-pi/child-pi.ts +20 -2
- package/src/runtime/child-pi/mock-fixtures.ts +23 -0
- package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
- package/src/runtime/compaction/important-line-classifier.ts +85 -11
- package/src/runtime/compaction/tool-output-pruner.ts +35 -11
- package/src/runtime/crew-agent-records.ts +32 -14
- package/src/runtime/delivery-coordinator.ts +30 -0
- package/src/runtime/dispatch-batch.ts +9 -6
- package/src/runtime/dwf-state-store.ts +41 -7
- package/src/runtime/finalize-run.ts +13 -2
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
- package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/live-session/embedded-tier.ts +3 -1
- package/src/runtime/live-session/live-session-runtime.ts +54 -1
- package/src/runtime/model/model-fallback.ts +23 -1
- package/src/runtime/output/pi-json-output.ts +26 -1
- package/src/runtime/policy-engine.ts +18 -0
- package/src/runtime/process/zombie-scanner.ts +84 -16
- package/src/runtime/process-status.ts +6 -1
- package/src/runtime/recovery/crash-recovery.ts +170 -28
- package/src/runtime/rpc/rpc-worker.ts +44 -1
- package/src/runtime/scheduling/run-coalesced-task-group.ts +73 -6
- package/src/runtime/scheduling/scheduler.ts +74 -11
- package/src/runtime/scratchpad/engine.ts +26 -3
- package/src/runtime/settings-store.ts +22 -2
- package/src/runtime/stale-reconciler.ts +4 -1
- package/src/runtime/surface/tmux-provider.ts +24 -3
- package/src/runtime/task-health.ts +16 -1
- package/src/runtime/task-output-context.ts +143 -71
- package/src/runtime/task-packet.ts +70 -17
- package/src/runtime/task-runner/child-executor.ts +27 -0
- package/src/runtime/task-runner/output-splitter.ts +79 -16
- package/src/runtime/task-runner/post-execution.ts +75 -15
- package/src/runtime/task-runner/state-helpers.ts +92 -2
- package/src/runtime/usage-tracker.ts +11 -0
- package/src/runtime/verification/green-contract.ts +17 -0
- package/src/runtime/verification/verification-gates.ts +24 -6
- package/src/runtime/workspace-lock.ts +53 -0
- package/src/schema/team-tool-schema.ts +10 -4
- package/src/state/coordination/mailbox.ts +133 -38
- package/src/state/decision-ledger.ts +56 -8
- package/src/state/event-log/cursor.ts +14 -2
- package/src/state/event-log/event-log-rotation.ts +83 -5
- package/src/state/event-log/event-log.ts +181 -12
- package/src/state/stores/active-run-registry.ts +10 -3
- package/src/state/types.ts +9 -1
- package/src/ui/agents-jobs-browser.ts +22 -8
- package/src/ui/dashboard-panes/agents-pane.ts +5 -2
- package/src/ui/events-state-source.ts +67 -28
- package/src/ui/inline-panel/agent-pane.ts +13 -6
- package/src/ui/inline-panel/agent-transcript.ts +33 -21
- package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
- package/src/ui/inline-panel/index.ts +21 -1
- package/src/ui/live-run-sidebar.ts +14 -3
- package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
- package/src/ui/powerbar-publisher.ts +6 -3
- package/src/ui/rail.ts +7 -1
- package/src/ui/render-scheduler.ts +53 -1
- package/src/ui/run-action-dispatcher.ts +68 -20
- package/src/ui/status-colors.ts +9 -0
- package/src/utils/env-filter.ts +8 -0
|
@@ -24,6 +24,8 @@ import { dirname } from "node:path";
|
|
|
24
24
|
import { createInterface } from "node:readline";
|
|
25
25
|
import { fileURLToPath } from "node:url";
|
|
26
26
|
|
|
27
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
28
|
+
|
|
27
29
|
/** D6 read-side cap (Checkpoint B, MINOR-2): restoreState refuses a file larger
|
|
28
30
|
* than this — an independent guard from the lifecycle cap (same value, no
|
|
29
31
|
* import to keep engine free of a lifecycle cycle). Bounds v8.deserialize
|
|
@@ -80,6 +82,10 @@ export interface RestoreResult {
|
|
|
80
82
|
path: string;
|
|
81
83
|
restored: string[];
|
|
82
84
|
failed: { name: string; reason: string }[];
|
|
85
|
+
/** P-5 (DR-04 round-7): set when the snapshot was REJECTED before restore —
|
|
86
|
+
* e.g. `unsupported-snapshot-version:<v>`. Distinguishes "restored 0 vars
|
|
87
|
+
* from a valid v1 snapshot" from "refused an unknown format". */
|
|
88
|
+
reason?: string;
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
export interface EngineOptions {
|
|
@@ -353,7 +359,14 @@ export class EngineManager {
|
|
|
353
359
|
*/
|
|
354
360
|
async dispose(snapshotPath?: string): Promise<void> {
|
|
355
361
|
if (snapshotPath && this.engineState === "running") {
|
|
356
|
-
await this.snapshotState(snapshotPath).catch(() =>
|
|
362
|
+
await this.snapshotState(snapshotPath).catch((error: unknown) => {
|
|
363
|
+
// E2 (DR-02): the guest is SIGKILLed below regardless and dispose
|
|
364
|
+
// must never throw — but silently discarding the FINAL snapshot made
|
|
365
|
+
// crash-recovery quietly lose engine state. Surface the loss at
|
|
366
|
+
// warn before dropping it. Keep returning null (no rethrow).
|
|
367
|
+
logInternalError("scratchpad.final-snapshot-failed", error, `snapshotPath=${snapshotPath}`, "warn");
|
|
368
|
+
return null;
|
|
369
|
+
});
|
|
357
370
|
}
|
|
358
371
|
await this.kill();
|
|
359
372
|
}
|
|
@@ -620,11 +633,21 @@ export class EngineManager {
|
|
|
620
633
|
const st = fstatSync(fd);
|
|
621
634
|
if (!st.isFile() || st.isSymbolicLink()) return null;
|
|
622
635
|
if (st.size > SNAPSHOT_MAX_BYTES) return null;
|
|
623
|
-
// file validated → lazily start the engine, then read+restore via fd.
|
|
624
|
-
await this.start();
|
|
625
636
|
const payload = JSON.parse(readFileSync(fd, { encoding: "utf8" })) as {
|
|
637
|
+
version?: number;
|
|
626
638
|
vars?: Record<string, string>;
|
|
627
639
|
};
|
|
640
|
+
// P-5 (DR-04 round-7): version gate BEFORE starting the engine — an
|
|
641
|
+
// unknown snapshot format must fail LOUDLY (warn + explicit result with
|
|
642
|
+
// restored:[] and a reason), not silently fail-open into an empty
|
|
643
|
+
// namespace (which reads as "nothing to restore" to every caller).
|
|
644
|
+
if (payload.version !== 1) {
|
|
645
|
+
const reason = `unsupported-snapshot-version:${String(payload.version)}`;
|
|
646
|
+
logInternalError("scratchpad.restoreState", new Error(`${reason} (expected 1) — refusing restore`), path, "warn");
|
|
647
|
+
return { path, restored: [], failed: [], reason };
|
|
648
|
+
}
|
|
649
|
+
// file validated → lazily start the engine, then restore.
|
|
650
|
+
await this.start();
|
|
628
651
|
const vars = payload.vars ?? {};
|
|
629
652
|
const reply = await this.request({ type: "restore", id: randomUUID(), vars }, SNAPSHOT_REQUEST_TIMEOUT_MS);
|
|
630
653
|
if (reply.type !== "restore_result") return null;
|
|
@@ -39,6 +39,25 @@ function validateScheduledJob(job: unknown): boolean {
|
|
|
39
39
|
return typeof obj.id === "string" && obj.id.length > 0 && typeof obj.scheduleType === "string" && typeof obj.enabled === "boolean";
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* R6-2(c) (round-6 audit): a persisted `lastStatus:"running"` is an in-flight
|
|
44
|
+
* flag written by a process that may already be dead when the file is next
|
|
45
|
+
* read. It is meaningless at load time and lethal downstream: the scheduler's
|
|
46
|
+
* fire() treats "running" as backpressure (in-flight guard), so re-loading the
|
|
47
|
+
* flag verbatim made a job that was mid-run at crash/restart time skip EVERY
|
|
48
|
+
* later occurrence — scheduled jobs died after restart. Strip only the stale
|
|
49
|
+
* "running" flag at load; terminal statuses ("success"/"error") survive
|
|
50
|
+
* untouched, as does every other field.
|
|
51
|
+
*/
|
|
52
|
+
function normalizeStaleRunningJob(job: unknown): unknown {
|
|
53
|
+
if (!job || typeof job !== "object") return job;
|
|
54
|
+
const obj = job as Record<string, unknown>;
|
|
55
|
+
if (obj.lastStatus !== "running") return job;
|
|
56
|
+
const copy: Record<string, unknown> = { ...obj };
|
|
57
|
+
delete copy.lastStatus;
|
|
58
|
+
return copy;
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
function sanitizeSettings(raw: unknown): CrewSettings {
|
|
43
62
|
if (!raw || typeof raw !== "object") return {};
|
|
44
63
|
const r = raw as Record<string, unknown>;
|
|
@@ -74,9 +93,10 @@ function sanitizeSettings(raw: unknown): CrewSettings {
|
|
|
74
93
|
if (typeof r.notifierIntervalMs === "number" && r.notifierIntervalMs >= 1000) {
|
|
75
94
|
out.notifierIntervalMs = r.notifierIntervalMs;
|
|
76
95
|
}
|
|
77
|
-
// Pass through scheduledJobs after basic validation
|
|
96
|
+
// Pass through scheduledJobs after basic validation + stale-flag
|
|
97
|
+
// normalization (R6-2c — see normalizeStaleRunningJob).
|
|
78
98
|
if (Array.isArray(r.scheduledJobs)) {
|
|
79
|
-
out.scheduledJobs = (r.scheduledJobs as unknown[]).filter(validateScheduledJob);
|
|
99
|
+
out.scheduledJobs = (r.scheduledJobs as unknown[]).filter(validateScheduledJob).map(normalizeStaleRunningJob);
|
|
80
100
|
}
|
|
81
101
|
return out;
|
|
82
102
|
}
|
|
@@ -713,7 +713,10 @@ export function reconcileOrphanedTempWorkspaces(
|
|
|
713
713
|
const result = reconcileStaleRun(manifest, tasks, now);
|
|
714
714
|
if (result.repaired && result.repairedTasks) {
|
|
715
715
|
// Persist repaired tasks (atomic — temp+rename to survive mid-write crash)
|
|
716
|
-
|
|
716
|
+
// R2-H1 (fix): persist the FULL array (NEW-1 persistTasks), not the filtered
|
|
717
|
+
// stale subset — the subset write destroyed every sibling record (mirror
|
|
718
|
+
// crash-recovery.ts:1066).
|
|
719
|
+
atomicWriteJson(tasksPath, result.persistTasks ?? result.repairedTasks);
|
|
717
720
|
// Update manifest status
|
|
718
721
|
const updated = {
|
|
719
722
|
...manifest,
|
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
* onExit: MỘT interval 2s cho cả provider — mỗi tick gọi list-panes đúng
|
|
13
13
|
* một lần rồi đối chiếu mọi pane đang được theo dõi (không spawn N subprocess
|
|
14
14
|
* cho N pane). Pane biến khỏi list hoặc pane_dead=1 → "pane-closed" bắn đúng
|
|
15
|
-
* một lần. tmux call throw (binary mất đột ngột, server chết
|
|
15
|
+
* một lần. tmux call throw HAI lần liên tiếp (binary mất đột ngột, server chết
|
|
16
|
+
* — R7-13 strike counter; transient EAGAIN/EMFILE chỉ warn) → "mux-dead"
|
|
16
17
|
* cho mọi handle còn sống.
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
20
|
import { execFileSync } from "node:child_process";
|
|
20
21
|
|
|
22
|
+
import { logInternalError } from "../../utils/internal-error.ts";
|
|
21
23
|
import type { SurfaceDetection, SurfaceExitReason, SurfaceHandle, SurfaceProvider, SurfaceSpawnOpts } from "./surface-provider.ts";
|
|
22
24
|
import { MAX_PANES_PER_TAB, splitDirectionFor } from "./surface-provider.ts";
|
|
23
25
|
|
|
@@ -111,6 +113,11 @@ export function createTmuxProvider(deps: TmuxProviderDeps = {}): SurfaceProvider
|
|
|
111
113
|
// Watcher theo pane id — MỘT timer chung cho mọi pane đang theo dõi.
|
|
112
114
|
const watchers = new Map<string, PaneWatcher>();
|
|
113
115
|
let timer: SurfaceTimer | null = null;
|
|
116
|
+
// R7-13 (DR-04 round-7): consecutive-failure strike counter cho poll list-panes.
|
|
117
|
+
// 1 lỗi transient (EAGAIN/EMFILE, binary hog dụng một chốc) KHÔNG được giết
|
|
118
|
+
// hết watcher — chỉ tăng strike + log warn; đến strike thứ 2 liên tiếp mới
|
|
119
|
+
// kết luận mux chết thật (semantics cũ). Poll thành công bất kỳ → reset về 0.
|
|
120
|
+
let muxFailStrikes = 0;
|
|
114
121
|
|
|
115
122
|
// Tab-layout (spec 2026-08-27-surface-tab-layout): tabKey(run) → MỌI window
|
|
116
123
|
// của run + số pane đã spawn trong window đang nhận. Tab mở khi run spawn
|
|
@@ -153,11 +160,25 @@ export function createTmuxProvider(deps: TmuxProviderDeps = {}): SurfaceProvider
|
|
|
153
160
|
// còn không -a thì chỉ thấy current window (pane-closed giả khi user
|
|
154
161
|
// switch window giữa chừng).
|
|
155
162
|
stdout = tmux(["list-panes", "-a", "-F", "#{pane_dead} #{pane_id}"]);
|
|
156
|
-
} catch {
|
|
157
|
-
//
|
|
163
|
+
} catch (error) {
|
|
164
|
+
// R7-13: strike counter — ≥2 lỗi list-panes LIÊN TIẾP mới coi là mux chết.
|
|
165
|
+
// Lỗi đơn lẻ (transient EAGAIN/EMFILE…) → giữ nguyên watchers + warn,
|
|
166
|
+
// tick kế vẫn poll (counter được reset bởi bất kỳ poll thành công nào).
|
|
167
|
+
muxFailStrikes += 1;
|
|
168
|
+
if (muxFailStrikes < 2) {
|
|
169
|
+
logInternalError(
|
|
170
|
+
"tmux-provider",
|
|
171
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
172
|
+
`list-panes failed (strike ${muxFailStrikes}/2) — keeping pane watchers alive`,
|
|
173
|
+
"warn",
|
|
174
|
+
);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// tmux binary mất đột ngột / server chết thật sự — mọi handle còn sống thành mux-dead.
|
|
158
178
|
for (const paneId of [...watchers.keys()]) fire(paneId, "mux-dead");
|
|
159
179
|
return;
|
|
160
180
|
}
|
|
181
|
+
muxFailStrikes = 0;
|
|
161
182
|
const status = parsePaneStatus(stdout);
|
|
162
183
|
for (const [paneId, watcher] of [...watchers]) {
|
|
163
184
|
// Không có trong list (đã dọn) hoặc pane_dead=1 → đóng.
|
|
@@ -32,6 +32,10 @@ export interface TaskSummary {
|
|
|
32
32
|
id: string;
|
|
33
33
|
status: string;
|
|
34
34
|
stalledSince?: number;
|
|
35
|
+
/** L-6 (2026-10-16): last worker heartbeat (tasks already carry
|
|
36
|
+
* `heartbeat.lastSeenAt` — no new writer needed). Preferred over
|
|
37
|
+
* `stalledSince`, which has no writer. */
|
|
38
|
+
heartbeat?: { lastSeenAt: string };
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export interface ManifestSummary {
|
|
@@ -47,7 +51,18 @@ export function computeRunHealth(manifest: ManifestSummary): RunHealth {
|
|
|
47
51
|
if (taskCount === 0) return { score: 100, grade: "A", penalties: [], deltas: [] };
|
|
48
52
|
|
|
49
53
|
const failedCount = tasks.filter((t) => t.status === "failed").length;
|
|
50
|
-
|
|
54
|
+
// L-6: stall detection derived from the task's own heartbeat (already
|
|
55
|
+
// written by worker heartbeat tracking — no new writer). Only RUNNING
|
|
56
|
+
// tasks can stall. `stalledSince` (no known writer) stays as a fallback
|
|
57
|
+
// for summaries without heartbeat data.
|
|
58
|
+
const stalledCount = tasks.filter((t) => {
|
|
59
|
+
if (t.status !== "running") return false;
|
|
60
|
+
if (t.heartbeat) {
|
|
61
|
+
const lastSeenAt = Date.parse(t.heartbeat.lastSeenAt);
|
|
62
|
+
return Number.isFinite(lastSeenAt) && Date.now() - lastSeenAt > STALLED_THRESHOLD_MS;
|
|
63
|
+
}
|
|
64
|
+
return t.stalledSince !== undefined && Date.now() - t.stalledSince > STALLED_THRESHOLD_MS;
|
|
65
|
+
}).length;
|
|
51
66
|
|
|
52
67
|
const failureRate = failedCount / taskCount;
|
|
53
68
|
if (failureRate > 0.2) {
|
|
@@ -6,9 +6,11 @@ import { atomicWriteFile } from "../state/atomic-write.ts";
|
|
|
6
6
|
import { writeArtifact } from "../state/stores/artifact-store.ts";
|
|
7
7
|
import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
8
8
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
9
|
+
import { countTokens } from "../utils/token-counter.ts";
|
|
9
10
|
import type { WorkflowStep } from "../workflows/workflow-config.ts";
|
|
10
11
|
import { applyCompactPipeline } from "./compaction/compact-pipeline.ts";
|
|
11
12
|
import { ANSI_STRIP_STAGE, BLANK_COLLAPSE_STAGE, TruncationStage } from "./compaction/compact-stages/index.ts";
|
|
13
|
+
import { splitWithImportantLines } from "./compaction/important-line-classifier.ts";
|
|
12
14
|
import { DEFAULT_PRUNE_CONFIG, type FileEditEvent, pruneToolOutputs, type ToolResultEntry } from "./compaction/tool-output-pruner.ts";
|
|
13
15
|
|
|
14
16
|
export interface DependencyContextEntry {
|
|
@@ -47,6 +49,13 @@ export interface DependencyContextEntry {
|
|
|
47
49
|
* block for marked entries; `writeTaskInputsArtifact` still serializes the
|
|
48
50
|
* ORIGINAL (untrimmed) context — the trim is render-only, never persisted. */
|
|
49
51
|
budgetTrimmed?: boolean;
|
|
52
|
+
/** G6 (DR-02 round-5): set by {@link enforceDependencyInlineBudget} when the
|
|
53
|
+
* 96KB layer downgraded this dep to path-only (`resultSummary: ""`). The
|
|
54
|
+
* renderer uses it to emit "[result withheld by inline budget — see
|
|
55
|
+
* artifact path above]" instead of the false "(no result output)" — the
|
|
56
|
+
* output EXISTS on disk at `resultPath`; only the inline copy was
|
|
57
|
+
* budgeted away. */
|
|
58
|
+
resultWithheldByBudget?: boolean;
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
export interface DependencyOutputContext {
|
|
@@ -302,6 +311,22 @@ function aggregateUsage(task: TeamTaskState): DependencyContextEntry["usage"] {
|
|
|
302
311
|
return { inputTokens, outputTokens, durationMs };
|
|
303
312
|
}
|
|
304
313
|
|
|
314
|
+
/** L4 + handoff-budget shared priority score (higher = keep inline / keep
|
|
315
|
+
* FULL form longer). R5-5 (DR-02): the handoff-budget trim
|
|
316
|
+
* (`applyHandoffBudget`) reuses the exact scoring the 96KB L4 layer
|
|
317
|
+
* (`enforceDependencyInlineBudget`) uses so a failed/needs-attention dep is
|
|
318
|
+
* the LAST thing either budget layer trims.
|
|
319
|
+
*
|
|
320
|
+
* Score: (highPriority?1000:0) + (recency/1e10) + (relevance*10) - (index*0.001)
|
|
321
|
+
* The 1000 base ensures high-priority wins even with no recency/relevance.
|
|
322
|
+
* recency/1e10 keeps it in [0, 1] for ms timestamps up to 1e13 (~year 2286).
|
|
323
|
+
* relevance*10 puts it in [0, 10].
|
|
324
|
+
* index*0.001 keeps stable order within the same bucket. */
|
|
325
|
+
function inlinePriorityScore(dep: DependencyContextEntry, originalIndex: number): number {
|
|
326
|
+
const highPriority = dep.status === "failed" || dep.status === "needs-attention";
|
|
327
|
+
return (highPriority ? 1000 : 0) + (dep.recency ?? 0) / 1e10 + (dep.relevance ?? 0) * 10 - originalIndex * 0.001;
|
|
328
|
+
}
|
|
329
|
+
|
|
305
330
|
/**
|
|
306
331
|
* L4 total-budget trim. When the SUM of `inlineBytes` across all deps
|
|
307
332
|
* exceeds {@link MAX_TOTAL_DEP_INLINE_BYTES}, downgrade the lowest-priority
|
|
@@ -330,16 +355,11 @@ export function enforceDependencyInlineBudget(dependencies: DependencyContextEnt
|
|
|
330
355
|
// Build an index-keyed array so we can sort AND preserve the original
|
|
331
356
|
// position (for stable output order). Each entry gets a numeric
|
|
332
357
|
// priority score (higher = more important) and a stable tie-breaker.
|
|
333
|
-
const indexed = dependencies.map((dep, originalIndex) => {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
// relevance*10 puts it in [0, 10].
|
|
339
|
-
// index*0.001 keeps stable order within the same bucket.
|
|
340
|
-
const score = (highPriority ? 1000 : 0) + (dep.recency ?? 0) / 1e10 + (dep.relevance ?? 0) * 10 - originalIndex * 0.001;
|
|
341
|
-
return { dep, originalIndex, score };
|
|
342
|
-
});
|
|
358
|
+
const indexed = dependencies.map((dep, originalIndex) => ({
|
|
359
|
+
dep,
|
|
360
|
+
originalIndex,
|
|
361
|
+
score: inlinePriorityScore(dep, originalIndex),
|
|
362
|
+
}));
|
|
343
363
|
const sorted = [...indexed].sort((a, b) => b.score - a.score);
|
|
344
364
|
let used = 0;
|
|
345
365
|
const keepSet = new Set<number>(); // originalIndex values to KEEP inline
|
|
@@ -358,6 +378,10 @@ export function enforceDependencyInlineBudget(dependencies: DependencyContextEnt
|
|
|
358
378
|
resultSummary: "",
|
|
359
379
|
structuredResults: undefined,
|
|
360
380
|
inlineBytes: 0,
|
|
381
|
+
// G6 (DR-02): flag the path-only downgrade so the renderer says the
|
|
382
|
+
// output was withheld by the inline budget instead of falling into the
|
|
383
|
+
// false "(no result output)" (the output exists at resultPath).
|
|
384
|
+
resultWithheldByBudget: true,
|
|
361
385
|
};
|
|
362
386
|
});
|
|
363
387
|
}
|
|
@@ -548,34 +572,34 @@ function sanitizeFencedBody(body: string): string {
|
|
|
548
572
|
return body.replace(DEPENDENCY_CONTROL_CHAR_PATTERN, "").replace(/<\/dependency-context/g, "</dependency-context");
|
|
549
573
|
}
|
|
550
574
|
|
|
551
|
-
// ── Handoff budget (effective-token cap on this layer:
|
|
552
|
-
// anchored upward by measured
|
|
575
|
+
// ── Handoff budget (effective-token cap on this layer: countTokens — ──
|
|
576
|
+
// CJK-aware, in-tree utils/token-counter.ts — anchored upward by measured
|
|
577
|
+
// worker usage, U10) ───────────────────────────────────────────────────────
|
|
553
578
|
|
|
554
579
|
/** Default effective-token budget for the dynamic.dependencyContext layer
|
|
555
|
-
* (tokens as measured by
|
|
556
|
-
*
|
|
580
|
+
* (tokens as measured by `countTokens` (utils/token-counter.ts — R5-7,
|
|
581
|
+
* DR-02: CJK-aware; the old chars/4 heuristic underestimated CJK ~2-3x),
|
|
582
|
+
* floored by measured worker usage where available — U10). */
|
|
557
583
|
export const DEFAULT_HANDOFF_BUDGET_TOKENS = 1800;
|
|
558
584
|
/** Budget ceiling: a resolved budget above this is treated as the off-switch
|
|
559
585
|
* (same semantics as ≤0 — clearly not a real handoff cap, so render
|
|
560
586
|
* untrimmed instead of guessing). */
|
|
561
587
|
export const HANDOFF_BUDGET_CEILING_TOKENS = 1_000_000;
|
|
562
|
-
/** Summary head kept per dependency when the budget trims it.
|
|
588
|
+
/** Summary head+tail budget kept per dependency when the budget trims it.
|
|
589
|
+
* R5-5 (DR-02): the compact form is no longer a plain head slice — the
|
|
590
|
+
* summary runs through `splitWithImportantLines` (75% head + important
|
|
591
|
+
* middle lines + 25% tail) so error/failed/exception lines from ANYWHERE in
|
|
592
|
+
* the summary survive, matching the full-form TruncationStage behavior. */
|
|
563
593
|
export const HANDOFF_SUMMARY_HEAD_CHARS = 240;
|
|
594
|
+
/** Slack fraction of {@link HANDOFF_SUMMARY_HEAD_CHARS} available for
|
|
595
|
+
* important middle lines in the compact form (R5-5). The classifier default
|
|
596
|
+
* (0.15 = 36 chars) is too tight for realistic diagnostics; 0.25 (60 chars)
|
|
597
|
+
* admits 1-2 typical error lines while keeping the compact entry small. */
|
|
598
|
+
const HANDOFF_COMPACT_IMPORTANT_SLACK = 0.25;
|
|
564
599
|
|
|
565
600
|
/** Strict integer env syntax — "12abc" must NOT parse as 12. */
|
|
566
601
|
const HANDOFF_BUDGET_ENV_RE = /^[+-]?\d+$/;
|
|
567
602
|
|
|
568
|
-
/** Est tokens = chars/4 — the same heuristic as `estimateTokens(chars)` in
|
|
569
|
-
* src/runtime/task-runner/prompt-builder.ts (SR-02 breakdown). Duplicated
|
|
570
|
-
* here instead of imported: importing prompt-builder would pull its
|
|
571
|
-
* retrieval/workspace-tree graph into every task-output-context consumer
|
|
572
|
-
* (post-execution, aggregate outputs) for a one-line heuristic. Keep the
|
|
573
|
-
* two in sync if either changes; the U10 max(heuristic, measured) combine
|
|
574
|
-
* rule mirrors prompt-builder's `estimateTokens(chars, measuredTokens)`. */
|
|
575
|
-
function estimateHandoffTokens(chars: number): number {
|
|
576
|
-
return Math.round(chars / 4);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
603
|
/** U10 measured-token anchor: Σ measured `usage.outputTokens` over the deps
|
|
580
604
|
* still rendered in FULL form. The usage record is the worker's REAL
|
|
581
605
|
* tokenizer measurement (message_end usage persisted on the task state via
|
|
@@ -590,9 +614,10 @@ function estimateHandoffTokens(chars: number): number {
|
|
|
590
614
|
* path is the compact form's artifact pointer.
|
|
591
615
|
*
|
|
592
616
|
* Compact (`budgetTrimmed`) deps are deliberately NOT charged: their inline
|
|
593
|
-
* body is a ≤240-char head + pointer, no longer the worker's output, so
|
|
594
|
-
*
|
|
595
|
-
* output-heavy dep could never fit any budget even fully
|
|
617
|
+
* body is a ≤240-char head/tail + pointer, no longer the worker's output, so
|
|
618
|
+
* the countTokens heuristic on the small body is the honest measure —
|
|
619
|
+
* otherwise an output-heavy dep could never fit any budget even fully
|
|
620
|
+
* trimmed. */
|
|
596
621
|
function measuredHandoffTokens(context: DependencyOutputContext): number {
|
|
597
622
|
let measured = 0;
|
|
598
623
|
for (const dep of context.dependencies) {
|
|
@@ -602,19 +627,18 @@ function measuredHandoffTokens(context: DependencyOutputContext): number {
|
|
|
602
627
|
return measured;
|
|
603
628
|
}
|
|
604
629
|
|
|
605
|
-
/** Measure a candidate context in EFFECTIVE tokens (U10): the
|
|
606
|
-
* heuristic on the rendered body,
|
|
607
|
-
*
|
|
608
|
-
* the
|
|
609
|
-
*
|
|
610
|
-
*
|
|
611
|
-
* (
|
|
612
|
-
*
|
|
630
|
+
/** Measure a candidate context in EFFECTIVE tokens (U10): the
|
|
631
|
+
* `countTokens` heuristic on the rendered body (R5-7, DR-02: CJK-aware —
|
|
632
|
+
* replaces the chars/4 estimate that under-counted CJK summaries ~2-3x),
|
|
633
|
+
* floored by the measured-token anchor — `max(heuristic, Σ measured output
|
|
634
|
+
* tokens of full-form deps)`, mirroring the render byte-for-byte (same parts
|
|
635
|
+
* builder; `.trim()` matches the render join). The max rule mirrors
|
|
636
|
+
* `estimateTokens(chars, measuredTokens)` in prompt-builder.ts: a real
|
|
637
|
+
* measurement can only TIGHTEN the estimate (spec U10 — the budget must
|
|
638
|
+
* never be exceeded when measured usage is larger than the heuristic),
|
|
639
|
+
* never loosen it below the heuristic. */
|
|
613
640
|
function measureHandoffTokens(context: DependencyOutputContext): number {
|
|
614
|
-
return Math.max(
|
|
615
|
-
estimateHandoffTokens(buildDependencyOutputBodyParts(context).join("\n").trim().length),
|
|
616
|
-
measuredHandoffTokens(context),
|
|
617
|
-
);
|
|
641
|
+
return Math.max(countTokens(buildDependencyOutputBodyParts(context).join("\n").trim()), measuredHandoffTokens(context));
|
|
618
642
|
}
|
|
619
643
|
|
|
620
644
|
/** Budget active iff it is a positive finite number at or below the ceiling;
|
|
@@ -646,7 +670,11 @@ function dependencyEntryLines(dep: DependencyContextEntry): string[] {
|
|
|
646
670
|
`Status: ${dep.status}`,
|
|
647
671
|
dep.resultPath ? `Result artifact: ${dep.resultPath}` : "",
|
|
648
672
|
"",
|
|
649
|
-
|
|
673
|
+
// G6 (DR-02 round-5): an L4 path-only downgrade has output on disk —
|
|
674
|
+
// never fall into the "(no result output)" lie for it.
|
|
675
|
+
dep.resultWithheldByBudget
|
|
676
|
+
? "[result withheld by inline budget — see artifact path above]"
|
|
677
|
+
: dep.resultSummary?.trim() || "(no result output)",
|
|
650
678
|
"",
|
|
651
679
|
];
|
|
652
680
|
// P1-A dependency tee-recovery hint: when the dependency's result was
|
|
@@ -662,18 +690,35 @@ function dependencyEntryLines(dep: DependencyContextEntry): string[] {
|
|
|
662
690
|
}
|
|
663
691
|
|
|
664
692
|
/** Compact handoff-budget form for one dependency (pre-sanitize): keeps the
|
|
665
|
-
* taskId/role/status lines + a
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
669
|
-
*
|
|
670
|
-
*
|
|
671
|
-
*
|
|
693
|
+
* taskId/role/status lines + a head/tail summary budget of
|
|
694
|
+
* {@link HANDOFF_SUMMARY_HEAD_CHARS} chars with important-line preservation
|
|
695
|
+
* (R5-5, DR-02: the old plain 240-char HEAD slice dropped a failed dep's
|
|
696
|
+
* error tail and any diagnostic beyond char 240 — the summary now runs
|
|
697
|
+
* through the same `splitWithImportantLines` the full-form TruncationStage
|
|
698
|
+
* path uses, so error/failed/exception lines from ANYWHERE in the summary
|
|
699
|
+
* survive) + a `[trimmed, N chars total]` marker when the summary was longer
|
|
700
|
+
* + a relative artifact pointer the worker can `read`. Drops
|
|
701
|
+
* structuredResults, usage, artifactsProduced and the absolute-path lines —
|
|
702
|
+
* all recoverable via the pointer. The pointer is built ONLY from
|
|
703
|
+
* manifest-derived runId/taskId (same trust level as the existing
|
|
704
|
+
* `## <taskId>` headers and the results/<taskId>.txt artifact naming);
|
|
705
|
+
* dep-controlled content never reaches it. */
|
|
672
706
|
function budgetTrimmedDependencyEntryLines(dep: DependencyContextEntry, runId: string | undefined): string[] {
|
|
673
707
|
const summary = dep.resultSummary?.trim() ?? "";
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
708
|
+
let body: string;
|
|
709
|
+
if (dep.resultWithheldByBudget) {
|
|
710
|
+
// G6 (DR-02): compact form of an L4-downgraded dep — same anti-lie
|
|
711
|
+
// marker; the pointer line below is the recovery path.
|
|
712
|
+
body = "[result withheld by inline budget — see full output path below]";
|
|
713
|
+
} else if (summary.length <= HANDOFF_SUMMARY_HEAD_CHARS) {
|
|
714
|
+
body = summary || "(no result output)";
|
|
715
|
+
} else {
|
|
716
|
+
const split = splitWithImportantLines(summary, HANDOFF_SUMMARY_HEAD_CHARS, {
|
|
717
|
+
slackFactor: HANDOFF_COMPACT_IMPORTANT_SLACK,
|
|
718
|
+
});
|
|
719
|
+
const segments = [split.head, ...split.importantLines, split.tail].filter((segment) => segment.length > 0);
|
|
720
|
+
body = `${segments.join("\n")}\n[trimmed, ${summary.length} chars total]`;
|
|
721
|
+
}
|
|
677
722
|
return [
|
|
678
723
|
`## ${dep.taskId} (${dep.role})`,
|
|
679
724
|
`Status: ${dep.status}`,
|
|
@@ -715,9 +760,10 @@ function buildDependencyOutputBodyParts(context: DependencyOutputContext): strin
|
|
|
715
760
|
|
|
716
761
|
/**
|
|
717
762
|
* Handoff budget: trim the dependency-output context to an EFFECTIVE-token
|
|
718
|
-
* budget (U10). Effective tokens = max(
|
|
719
|
-
* body
|
|
720
|
-
*
|
|
763
|
+
* budget (U10). Effective tokens = max(countTokens heuristic on the rendered
|
|
764
|
+
* body — R5-7 (DR-02): CJK-aware, from utils/token-counter.ts — Σ measured
|
|
765
|
+
* `usage.outputTokens` of full-form deps — real tokenizer numbers from
|
|
766
|
+
* message_end usage persisted on the task state). Measured
|
|
721
767
|
* usage can only tighten: when a worker reports MORE tokens than the
|
|
722
768
|
* heuristic estimates, the measured number governs and the trim fires at
|
|
723
769
|
* least as early as before (spec U10 acceptance — the budget is never
|
|
@@ -727,14 +773,20 @@ function buildDependencyOutputBodyParts(context: DependencyOutputContext): strin
|
|
|
727
773
|
* input reference unchanged when the budget is off (≤0 or >1_000_000) or
|
|
728
774
|
* the context already fits.
|
|
729
775
|
*
|
|
730
|
-
* Trim policy (
|
|
731
|
-
*
|
|
732
|
-
*
|
|
733
|
-
*
|
|
734
|
-
*
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
776
|
+
* Trim policy (R5-5, DR-02 — failed-priority, mirroring the 96KB layer):
|
|
777
|
+
* dependencies are probed from ALL-full downward in DOWNGRADE-PRIORITY
|
|
778
|
+
* order — the same score `enforceDependencyInlineBudget` uses
|
|
779
|
+
* (`inlinePriorityScore`: status failed/needs-attention gets the +1000
|
|
780
|
+
* boost, then recency, then relevance, then declaration index). The
|
|
781
|
+
* lowest-keep-priority deps are downgraded to the compact form
|
|
782
|
+
* (taskId/role/status + head/tail summary ≤240 chars with important-line
|
|
783
|
+
* preservation + `artifacts/<runId>/results/<taskId>.txt` pointer) FIRST;
|
|
784
|
+
* failed/needs-attention deps therefore keep their FULL form longest — the
|
|
785
|
+
* downstream worker must see the failure. Declaration order still
|
|
786
|
+
* guarantees that among same-priority deps, earlier ones keep ≥ as much as
|
|
787
|
+
* later ones. Long (>240 chars) sharedRead bodies are replaced by a trim
|
|
788
|
+
* marker (the Path pointer is already rendered); short shared reads pass
|
|
789
|
+
* through.
|
|
738
790
|
*
|
|
739
791
|
* When even the fully-trimmed form exceeds the budget (extreme dependency
|
|
740
792
|
* count), the fully-trimmed context is returned as the bounded best effort —
|
|
@@ -757,16 +809,36 @@ export function applyHandoffBudget(context: DependencyOutputContext, budgetToken
|
|
|
757
809
|
? { ...read, content: `[content trimmed, ${content.length} chars total — read Path above for the full file]` }
|
|
758
810
|
: read;
|
|
759
811
|
};
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
812
|
+
// R5-5 (DR-02): downgrade ORDER — ascending keep-priority (the FIRST
|
|
813
|
+
// entries are downgraded first). Reuses the exact scoring the 96KB L4
|
|
814
|
+
// layer uses (`inlinePriorityScore`) so failed/needs-attention deps land
|
|
815
|
+
// LAST and keep their FULL form longest; within the same priority bucket
|
|
816
|
+
// later-declared deps are downgraded before earlier ones.
|
|
817
|
+
const downgradeOrder = base.dependencies
|
|
818
|
+
.map((dep, originalIndex) => ({ originalIndex, score: inlinePriorityScore(dep, originalIndex) }))
|
|
819
|
+
.sort((a, b) => a.score - b.score)
|
|
820
|
+
.map((item) => item.originalIndex);
|
|
821
|
+
const candidateAt = (downgradedCount: number): DependencyOutputContext => {
|
|
822
|
+
// Downgrade the `downgradedCount` LOWEST-keep-priority deps first
|
|
823
|
+
// (R5-5): failed/needs-attention deps are last in `downgradeOrder`, so
|
|
824
|
+
// they keep their FULL form longest.
|
|
825
|
+
const downgradedIndexes = new Set(downgradeOrder.slice(0, downgradedCount));
|
|
826
|
+
return {
|
|
827
|
+
...base,
|
|
828
|
+
dependencies: base.dependencies.map((dep, index) => (downgradedIndexes.has(index) ? { ...dep, budgetTrimmed: true } : dep)),
|
|
829
|
+
sharedReads: base.sharedReads.map(trimmedRead),
|
|
830
|
+
};
|
|
831
|
+
};
|
|
832
|
+
// Probe from fewest-downgraded upward and return the first candidate that
|
|
833
|
+
// fits — the maximum FULL-form set under the failed-priority order. More
|
|
834
|
+
// downgrades strictly shrink the measure (compact form < full form for
|
|
835
|
+
// both the heuristic and the measured anchor), so the first fit is the
|
|
836
|
+
// best fit.
|
|
837
|
+
for (let downgradedCount = 0; downgradedCount <= base.dependencies.length; downgradedCount++) {
|
|
838
|
+
const candidate = candidateAt(downgradedCount);
|
|
767
839
|
if (measureHandoffTokens(candidate) <= budgetTokens) return candidate;
|
|
768
840
|
}
|
|
769
|
-
return candidateAt(
|
|
841
|
+
return candidateAt(base.dependencies.length);
|
|
770
842
|
}
|
|
771
843
|
|
|
772
844
|
export interface RenderDependencyOutputContextOptions {
|