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
|
@@ -21,7 +21,7 @@ import * as fs from "node:fs";
|
|
|
21
21
|
import * as path from "node:path";
|
|
22
22
|
import { childPiBaseAllowlist } from "../../config/env-allowlist-registry.ts";
|
|
23
23
|
import { isScratchpadEnabledForRole } from "../../config/role-tools.ts";
|
|
24
|
-
import { buildScopedAllowList, sanitizeEnvSecrets } from "../../utils/env-filter.ts";
|
|
24
|
+
import { buildScopedAllowList, providerEnvKeys, sanitizeEnvSecrets } from "../../utils/env-filter.ts";
|
|
25
25
|
import { logInternalError } from "../../utils/internal-error.ts";
|
|
26
26
|
import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
|
|
27
27
|
import { buildPiWorkerArgs, createSafeTempDir, getPiTempBase } from "../model/pi-args.ts";
|
|
@@ -48,8 +48,23 @@ export const BASE_ALLOWLIST: string[] = childPiBaseAllowlist();
|
|
|
48
48
|
* 4. Add PI_CREW_PARENT_PID for the child-side parent-guard (consumed by the
|
|
49
49
|
* reactive zombie-scanner + background-runner parent-guard; unused only by
|
|
50
50
|
* the pi dist binary — see note at the PI_CREW_PARENT_PID assignment below).
|
|
51
|
+
*
|
|
52
|
+
* R6-8 (DR-03 round-6): `modelChain` threads the task's model CANDIDATE CHAIN
|
|
53
|
+
* (primary + fallbacks, e.g. modelRouting.candidates) so the allowlist is
|
|
54
|
+
* scoped to the UNION of the chain's providers. Previously only the single
|
|
55
|
+
* current model was passed: a bare-id primary (no "provider/" prefix)
|
|
56
|
+
* resolved to ZERO provider env keys → the child failed auth with a
|
|
57
|
+
* non-retryable /api key/i error → the loop broke even though qualified
|
|
58
|
+
* fallbacks were queued (the env-filter.ts doc contract already promised
|
|
59
|
+
* "primary + fallbacks"). Scoping is NEVER weakened by this: keys stay
|
|
60
|
+
* limited to the chain's providers ONLY — never all-providers.
|
|
51
61
|
*/
|
|
52
|
-
export function buildChildPiSpawnOptions(
|
|
62
|
+
export function buildChildPiSpawnOptions(
|
|
63
|
+
cwd: string,
|
|
64
|
+
env: NodeJS.ProcessEnv,
|
|
65
|
+
model?: string,
|
|
66
|
+
modelChain?: (string | undefined)[],
|
|
67
|
+
): SpawnOptions {
|
|
53
68
|
// SECURITY FIX (Issue #1): Validate cwd before passing to spawn.
|
|
54
69
|
// If cwd comes from an untrusted source (user input, workspace config), a malicious cwd
|
|
55
70
|
// could cause the child process to operate in an attacker-controlled directory,
|
|
@@ -81,9 +96,33 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, mo
|
|
|
81
96
|
// Bug #12 fix: essential env vars (PATH, HOME, etc.) are always preserved so child can find npm/node.
|
|
82
97
|
//
|
|
83
98
|
// PER-TASK KEY SCOPING: when a model is provided, only the env keys for that
|
|
84
|
-
// provider are injected (via buildScopedAllowList).
|
|
85
|
-
//
|
|
86
|
-
|
|
99
|
+
// provider are injected (via buildScopedAllowList). R6-8: when the caller
|
|
100
|
+
// threads the model CANDIDATE CHAIN (primary + fallbacks), keys are scoped
|
|
101
|
+
// to the UNION of the chain's providers so a fallback attempt on a second
|
|
102
|
+
// provider does not spawn keyless. When no model is given, only
|
|
103
|
+
// BASE_ALLOWLIST system vars pass through — no provider keys leak.
|
|
104
|
+
const scopeModels: (string | undefined)[] = modelChain && modelChain.length > 0 ? modelChain : model !== undefined ? [model] : [];
|
|
105
|
+
const allowList = scopeModels.length > 0 ? buildScopedAllowList(BASE_ALLOWLIST, scopeModels) : BASE_ALLOWLIST;
|
|
106
|
+
// R6-8: warn when the scoped models resolve to ZERO provider env keys AND at
|
|
107
|
+
// least one is a truly-bare id (no "provider/" prefix) — env-key auth + bare
|
|
108
|
+
// id means the child worker will very likely fail authentication (observed as
|
|
109
|
+
// the R6-8 stall: bare id → zero keys → /api key/i non-retryable → chain break
|
|
110
|
+
// even though qualified fallbacks were queued). NOT an error, and it never
|
|
111
|
+
// widens the allowlist: users authenticating via auth.json (or custom
|
|
112
|
+
// providers with non-env credentials) are unaffected — the child reads
|
|
113
|
+
// auth.json credentials exactly like the parent process does. Qualified
|
|
114
|
+
// unknown providers ("custom/foo") do not warn: that is a deliberate
|
|
115
|
+
// custom-provider choice, not a likely misconfiguration.
|
|
116
|
+
const anyProviderKeys = scopeModels.some((scoped) => providerEnvKeys(scoped).length > 0);
|
|
117
|
+
const anyBareId = scopeModels.some((scoped) => typeof scoped === "string" && !scoped.includes("/"));
|
|
118
|
+
if (scopeModels.length > 0 && anyBareId && !anyProviderKeys) {
|
|
119
|
+
logInternalError(
|
|
120
|
+
"child-pi-spawn.bare-model-no-provider-keys",
|
|
121
|
+
undefined,
|
|
122
|
+
`model "${model ?? scopeModels.find((scoped) => typeof scoped === "string")}" is a bare id (no provider prefix) and resolved zero provider env keys — the child worker gets no provider API key via env; env-key users should qualify the model id (e.g. "openai/<id>") or configure a fallback chain (auth.json users are unaffected)`,
|
|
123
|
+
"warn",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
87
126
|
const filteredEnv = sanitizeEnvSecrets(env, { allowList });
|
|
88
127
|
// FIX: Removed delete workarounds — with explicit allowlist, these vars
|
|
89
128
|
// are no longer auto-leaked. The wildcard approach was fragile.
|
|
@@ -192,12 +231,14 @@ export function buildFinalChildPiSpawnOptions(
|
|
|
192
231
|
mergedEnv: NodeJS.ProcessEnv,
|
|
193
232
|
builtEnv: Record<string, string | undefined>,
|
|
194
233
|
model?: string,
|
|
234
|
+
modelChain?: (string | undefined)[],
|
|
195
235
|
): SpawnOptions {
|
|
196
236
|
// (a) Canary: builtEnv must contain ONLY PI_CREW_*/PI_TEAMS_* keys.
|
|
197
237
|
assertOnlyControlEnvKeys(builtEnv);
|
|
198
238
|
// (b) Build the allowlist-filtered base SpawnOptions (cwd validation, env
|
|
199
239
|
// filtering, provider-key scoping when model is set, NODE_PATH guard).
|
|
200
|
-
|
|
240
|
+
// R6-8: modelChain threads the candidate chain for provider-key scoping.
|
|
241
|
+
const spawnOptions = buildChildPiSpawnOptions(cwd, mergedEnv, model, modelChain);
|
|
201
242
|
// (c) Spread builtEnv back on top — the allowlist in step (b) intentionally
|
|
202
243
|
// strips PI_CREW_*/PI_TEAMS_* keys, so without this spread the child
|
|
203
244
|
// process would never see steering file, kind, role, or broker creds.
|
|
@@ -395,6 +436,3 @@ export function prepareSpawnContext(
|
|
|
395
436
|
},
|
|
396
437
|
};
|
|
397
438
|
}
|
|
398
|
-
|
|
399
|
-
// Silence unused-import warning for logInternalError if not consumed by future helpers.
|
|
400
|
-
void logInternalError;
|
|
@@ -170,6 +170,18 @@ export interface ChildPiRunInput {
|
|
|
170
170
|
task: string;
|
|
171
171
|
agent: AgentConfig;
|
|
172
172
|
model?: string;
|
|
173
|
+
/**
|
|
174
|
+
* R6-8 (DR-03 round-6): the task's model CANDIDATE CHAIN (primary +
|
|
175
|
+
* fallbacks, e.g. modelRouting.candidates / the executor's attemptModels)
|
|
176
|
+
* for provider env-key scoping in buildChildPiSpawnOptions. Scopes the
|
|
177
|
+
* allowlist to the UNION of the chain's providers — never all-providers —
|
|
178
|
+
* so a fallback attempt on a second provider does not spawn keyless, and
|
|
179
|
+
* a bare-id primary no longer means zero provider keys when a qualified
|
|
180
|
+
* fallback exists. Absent/empty → single-model scoping (previous
|
|
181
|
+
* behavior); a truly-bare single model then warns (env-key auth + bare id
|
|
182
|
+
* is a likely misconfiguration; auth.json users are unaffected).
|
|
183
|
+
*/
|
|
184
|
+
modelChain?: (string | undefined)[];
|
|
173
185
|
skillPaths?: string[];
|
|
174
186
|
signal?: AbortSignal;
|
|
175
187
|
transcriptPath?: string;
|
|
@@ -474,7 +486,12 @@ async function trySurfaceBranch(
|
|
|
474
486
|
// child's built env whose PI_CREW_DEPTH is parentDepth+1 — layer-1
|
|
475
487
|
// guard would misfire on our own tier-1 workers.
|
|
476
488
|
env: depthEnv ?? process.env,
|
|
477
|
-
|
|
489
|
+
// R6-8: thread the model candidate chain so the surface pane's worker env
|
|
490
|
+
// is provider-key scoped to primary + fallbacks (same as the headless path).
|
|
491
|
+
workerEnv: buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model, input.modelChain).env as Record<
|
|
492
|
+
string,
|
|
493
|
+
string
|
|
494
|
+
>,
|
|
478
495
|
config: surfaceConfig,
|
|
479
496
|
role: input.role ?? input.agent.name,
|
|
480
497
|
livePaneCount: degradeController ? degradeController.livePaneCount() : (surfaceOpts?.livePaneCount ?? 0),
|
|
@@ -801,7 +818,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
|
|
|
801
818
|
return await new Promise<ChildPiRunResult>((resolve) => {
|
|
802
819
|
// Compose the final SpawnOptions: canary + filter + spread are now
|
|
803
820
|
// owned by buildFinalChildPiSpawnOptions (see child-pi-spawn.ts, BLOCKER 2 / S5).
|
|
804
|
-
|
|
821
|
+
// R6-8: modelChain threads the candidate chain for provider-key scoping.
|
|
822
|
+
const spawnOptions = buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model, input.modelChain);
|
|
805
823
|
const child = spawn(spawnSpec.command, spawnSpec.args, spawnOptions);
|
|
806
824
|
if (child.pid) {
|
|
807
825
|
registerActiveChild(child.pid, child);
|
|
@@ -68,6 +68,29 @@ export async function runMockChildPi(
|
|
|
68
68
|
return { exitCode: 0, stdout, stderr: "" };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
if (mock === "json-429-empty-settle") {
|
|
72
|
+
// DR-01 P0 test seam: reproduces the live 429-exhaustion shape (runs
|
|
73
|
+
// team_20261014082741 / team_20261014093334): opening assistant text +
|
|
74
|
+
// tool results, then EMPTY assistant message_end events between
|
|
75
|
+
// auto-retry cycles, ending agent_settled — exit 0, NO stopReason error,
|
|
76
|
+
// and finalText keeps the early opening sentence ("finalText lies").
|
|
77
|
+
const opening = JSON.stringify({
|
|
78
|
+
type: "message_end",
|
|
79
|
+
message: { role: "assistant", content: [{ type: "text", text: `[MOCK] Starting work for ${input.agent.name}` }] },
|
|
80
|
+
});
|
|
81
|
+
const toolResult = JSON.stringify({
|
|
82
|
+
type: "message_end",
|
|
83
|
+
message: { role: "toolResult", content: [{ type: "text", text: "[MOCK] tool output" }] },
|
|
84
|
+
});
|
|
85
|
+
const emptyAssistant = JSON.stringify({
|
|
86
|
+
type: "message_end",
|
|
87
|
+
message: { role: "assistant", content: [], usage: { input: 0, output: 0 } },
|
|
88
|
+
});
|
|
89
|
+
const stdout = [opening, toolResult, emptyAssistant, emptyAssistant, JSON.stringify({ type: "agent_settled" })].join("\n") + "\n";
|
|
90
|
+
await observe(input, stdout);
|
|
91
|
+
return { exitCode: 0, stdout, stderr: "" };
|
|
92
|
+
}
|
|
93
|
+
|
|
71
94
|
if (mock === "json-success" || mock === "adaptive-plan") {
|
|
72
95
|
const text =
|
|
73
96
|
mock === "adaptive-plan" && effectiveTask.includes("ADAPTIVE_PLAN_JSON_START")
|
|
@@ -12,6 +12,17 @@
|
|
|
12
12
|
* their distinct separators. Defaults match `compactString`'s pre-P0-A output
|
|
13
13
|
* exactly so that callers that do not opt into additional stages get
|
|
14
14
|
* bit-identical output (L4 backward-compat safety).
|
|
15
|
+
*
|
|
16
|
+
* K6 (DR-03 round-6): budget accounting. The kept important lines are part
|
|
17
|
+
* of the OUTPUT, so their joined length (+ the "\n" seam) is reserved OUT
|
|
18
|
+
* of the head+tail budget (`splitWithImportantLines` `reserveChars`). The
|
|
19
|
+
* composed result therefore fits `maxChars` + {@link TRUNCATION_MARKER_ALLOWANCE}
|
|
20
|
+
* (marker budget) instead of the pre-K6 ~1.15×maxChars, where head+tail
|
|
21
|
+
* alone consumed the full budget and the important-line slack stacked on
|
|
22
|
+
* top unbudgeted. When no important lines are kept, sizing is bit-identical
|
|
23
|
+
* to the legacy behavior (no reserve). The stage's own monotonic-shrink
|
|
24
|
+
* invariant is preserved: if the composed result would be ≥ the input, the
|
|
25
|
+
* input is returned unchanged.
|
|
15
26
|
*/
|
|
16
27
|
import type { ICompactStage } from "../compact-pipeline.ts";
|
|
17
28
|
import { splitWithImportantLines } from "../important-line-classifier.ts";
|
|
@@ -34,6 +45,15 @@ const DEFAULT_MARKER: TruncationMarkerConfig = {
|
|
|
34
45
|
tailSeparator: "\n",
|
|
35
46
|
};
|
|
36
47
|
|
|
48
|
+
/**
|
|
49
|
+
* K6 "small constant" = the marker budget: the longest marker template
|
|
50
|
+
* (with-important-lines variant, 6-digit counters) plus separators plus
|
|
51
|
+
* digit drift. The head+tail budget absorbs the kept-important-line content
|
|
52
|
+
* (joined length + seam), so the composed result exceeds `maxChars` by at
|
|
53
|
+
* most this marker budget — never by the old unbudgeted ~15% slack.
|
|
54
|
+
*/
|
|
55
|
+
export const TRUNCATION_MARKER_ALLOWANCE = 160;
|
|
56
|
+
|
|
37
57
|
export class TruncationStage implements ICompactStage {
|
|
38
58
|
readonly id = "truncation";
|
|
39
59
|
private readonly maxChars: number;
|
|
@@ -55,19 +75,41 @@ export class TruncationStage implements ICompactStage {
|
|
|
55
75
|
}
|
|
56
76
|
apply(text: string): string {
|
|
57
77
|
if (text.length <= this.maxChars) return text;
|
|
58
|
-
|
|
78
|
+
// K6: two-pass budget accounting.
|
|
79
|
+
// Pass 1 (today's semantics, full-budget split) learns which important
|
|
80
|
+
// lines are kept — their joined length is part of the OUTPUT and must be
|
|
81
|
+
// reserved out of the head+tail budget instead of stacking on top.
|
|
82
|
+
const pass1 = splitWithImportantLines(text, this.maxChars, {
|
|
59
83
|
preserveImportant: this.preserveImportant,
|
|
60
84
|
});
|
|
85
|
+
const joined = pass1.importantLines.join("\n");
|
|
86
|
+
// Reserve exactly the kept content (+ the "\n" seam between marker and
|
|
87
|
+
// joined lines). The marker text + separators stay as the tolerated
|
|
88
|
+
// TRUNCATION_MARKER_ALLOWANCE overhead; when no important lines are
|
|
89
|
+
// kept the reserve is 0 and sizing is bit-identical to legacy.
|
|
90
|
+
const reserve = joined.length > 0 ? joined.length + 1 : 0;
|
|
91
|
+
// Pass 2 re-slices head+tail against the shrunken budget. It is a pure
|
|
92
|
+
// budgeted slice: preserveImportant:false skips the (already done)
|
|
93
|
+
// scan, and pass-1's lines are guaranteed to sit inside pass-2's
|
|
94
|
+
// dropped middle — the pass-2 middle is a superset of pass-1's because
|
|
95
|
+
// the shrunken head/tail regions are subsets of pass-1's.
|
|
96
|
+
const { head, tail, baseDropped } = splitWithImportantLines(text, this.maxChars, {
|
|
97
|
+
preserveImportant: false,
|
|
98
|
+
reserveChars: reserve,
|
|
99
|
+
});
|
|
100
|
+
const importantLines = pass1.importantLines;
|
|
61
101
|
let result: string;
|
|
62
102
|
if (importantLines.length === 0) {
|
|
63
103
|
result = `${head}${this.marker.headSeparator}...[pi-crew ${this.marker.verb} ${baseDropped} ${this.marker.unit}, head+tail preserved]...${this.marker.tailSeparator}${tail}`;
|
|
64
104
|
} else {
|
|
65
|
-
const joined = importantLines.join("\n");
|
|
66
105
|
const remaining = text.length - head.length - tail.length - joined.length;
|
|
67
106
|
result = `${head}${this.marker.headSeparator}...[pi-crew ${this.marker.verb} ${baseDropped} ${this.marker.unit}, head+tail + ${importantLines.length} important lines preserved, ${remaining} ${this.marker.unit} remaining dropped]...\n${joined}${this.marker.tailSeparator}${tail}`;
|
|
68
107
|
}
|
|
69
108
|
// Defense-in-depth: this stage's own monotonic-shrink invariant. The
|
|
70
|
-
// pipeline gate is a SECOND line of defense.
|
|
109
|
+
// pipeline gate is a SECOND line of defense. With the K6 budget the
|
|
110
|
+
// composed result is ≤ maxChars + marker budget, so this guard only
|
|
111
|
+
// fires for inputs barely over maxChars (where returning the input
|
|
112
|
+
// verbatim is still within the allowance) or arithmetic bugs.
|
|
71
113
|
if (result.length >= text.length) return text;
|
|
72
114
|
return result;
|
|
73
115
|
}
|
|
@@ -20,7 +20,55 @@
|
|
|
20
20
|
* "compacted ... chars, head+tail preserved") and `readIfSmall` (marker
|
|
21
21
|
* "truncated ... bytes, head+tail preserved") backward-compatible when no
|
|
22
22
|
* important lines are present.
|
|
23
|
+
*
|
|
24
|
+
* K6/K7 (DR-03 round-6):
|
|
25
|
+
* - `reserveChars` lets the caller (TruncationStage) subtract the composed
|
|
26
|
+
* marker + separator + joined-important-line overhead from the head+tail
|
|
27
|
+
* budget, so the FINAL composed result fits `maxChars` + small constant
|
|
28
|
+
* instead of stacking unbudgeted overhead on top (~1.15×maxChars).
|
|
29
|
+
* - Head/tail/middle boundaries are surrogate-pair-safe: a code-unit index
|
|
30
|
+
* that would split an astral pair (emoji, CJK extensions) is backed off
|
|
31
|
+
* so every emitted part contains only whole code points.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** Default fraction of `maxChars` available for important-line content. */
|
|
35
|
+
export const DEFAULT_SLACK_FACTOR = 0.15;
|
|
36
|
+
/** Default cap on candidate lines before slack-budget selection. */
|
|
37
|
+
export const DEFAULT_MAX_IMPORTANT_LINES = 30;
|
|
38
|
+
|
|
39
|
+
function isHighSurrogateUnit(codeUnit: number): boolean {
|
|
40
|
+
return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isLowSurrogateUnit(codeUnit: number): boolean {
|
|
44
|
+
return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* K7: back a head-end boundary off while it would split a surrogate pair.
|
|
49
|
+
* `value.slice(0, boundary)` must never end on a lone high surrogate — if the
|
|
50
|
+
* code unit at `boundary - 1` is a high surrogate, its low partner lives at
|
|
51
|
+
* `boundary` and slicing there would orphan both halves; the boundary walks
|
|
52
|
+
* back so the whole pair falls into the (dropped) middle instead.
|
|
23
53
|
*/
|
|
54
|
+
export function safeHeadBoundary(value: string, end: number): number {
|
|
55
|
+
let boundary = Math.max(0, Math.min(end, value.length));
|
|
56
|
+
while (boundary > 0 && isHighSurrogateUnit(value.charCodeAt(boundary - 1))) boundary -= 1;
|
|
57
|
+
return boundary;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* K7: advance a tail-start boundary while it would split a surrogate pair.
|
|
62
|
+
* `value.slice(boundary)` must never start on a lone low surrogate — if the
|
|
63
|
+
* code unit at `boundary` is a low surrogate, its high partner lives at
|
|
64
|
+
* `boundary - 1` and slicing there would orphan both halves; the boundary
|
|
65
|
+
* walks forward so the whole pair stays in the (dropped) middle instead.
|
|
66
|
+
*/
|
|
67
|
+
export function safeTailBoundary(value: string, start: number): number {
|
|
68
|
+
let boundary = Math.max(0, Math.min(start, value.length));
|
|
69
|
+
while (boundary < value.length && isLowSurrogateUnit(value.charCodeAt(boundary))) boundary += 1;
|
|
70
|
+
return boundary;
|
|
71
|
+
}
|
|
24
72
|
|
|
25
73
|
/** Diagnostic patterns. Anchored where safe to avoid matching noise. */
|
|
26
74
|
export const IMPORTANT_LINE_PATTERNS: readonly RegExp[] = [
|
|
@@ -75,7 +123,9 @@ export interface TruncationSplit {
|
|
|
75
123
|
* fit the slack budget.
|
|
76
124
|
*/
|
|
77
125
|
importantLines: string[];
|
|
78
|
-
/** `value.length -
|
|
126
|
+
/** `value.length - headTailBudget` — chars dropped if no important lines
|
|
127
|
+
* preserved. `headTailBudget` is `maxChars - reserveChars`; with no
|
|
128
|
+
* reserve (the default) this equals `value.length - maxChars`. */
|
|
79
129
|
baseDropped: number;
|
|
80
130
|
}
|
|
81
131
|
|
|
@@ -84,8 +134,19 @@ export interface SplitOptions {
|
|
|
84
134
|
preserveImportant?: boolean;
|
|
85
135
|
/** Hard cap on candidate lines before slack-budget selection. Default 30. */
|
|
86
136
|
maxImportantLines?: number;
|
|
87
|
-
/**
|
|
137
|
+
/**
|
|
138
|
+
* Fraction of `maxChars` available for important-line content. Default 0.15
|
|
139
|
+
* ({@link DEFAULT_SLACK_FACTOR}).
|
|
140
|
+
*/
|
|
88
141
|
slackFactor?: number;
|
|
142
|
+
/**
|
|
143
|
+
* K6: code units the CALLER will compose AROUND head+tail (marker text,
|
|
144
|
+
* separators, joined important lines). The head+tail budget shrinks by
|
|
145
|
+
* this reserve so the caller's final composed result fits `maxChars` +
|
|
146
|
+
* small constant instead of stacking unbudgeted overhead on top. Default
|
|
147
|
+
* 0 — legacy semantics where head+tail alone consume the full maxChars.
|
|
148
|
+
*/
|
|
149
|
+
reserveChars?: number;
|
|
89
150
|
}
|
|
90
151
|
|
|
91
152
|
/**
|
|
@@ -101,24 +162,37 @@ export function splitWithImportantLines(value: string, maxChars: number, opts: S
|
|
|
101
162
|
if (value.length <= maxChars) {
|
|
102
163
|
return { head: value, tail: "", importantLines: [], baseDropped: 0 };
|
|
103
164
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
165
|
+
// K6: reserve the caller's composed overhead (marker + separators +
|
|
166
|
+
// joined important lines) out of the head+tail budget. Clamped so at
|
|
167
|
+
// least one code unit of budget survives even when the reserve exceeds
|
|
168
|
+
// maxChars (pathologically small budgets — the marker alone dominates).
|
|
169
|
+
const reserve = Math.max(0, Math.min(Math.floor(opts.reserveChars ?? 0), maxChars - 1));
|
|
170
|
+
const headTailBudget = Math.max(1, maxChars - reserve);
|
|
171
|
+
const headLen = Math.floor(headTailBudget * 0.75);
|
|
172
|
+
const tailLen = headTailBudget - headLen;
|
|
173
|
+
// K7: surrogate-safe boundaries. Raw code-unit slices previously split
|
|
174
|
+
// astral pairs at the 75% head boundary and the tail start, emitting
|
|
175
|
+
// lone surrogates (mojibake) into the compacted output. Back off while
|
|
176
|
+
// the boundary would split a pair — the whole pair then falls into the
|
|
177
|
+
// middle (dropped) region instead of being broken across head/tail.
|
|
178
|
+
const headEnd = safeHeadBoundary(value, headLen);
|
|
179
|
+
const tailStart = safeTailBoundary(value, value.length - tailLen);
|
|
180
|
+
const head = value.slice(0, headEnd);
|
|
181
|
+
const tail = value.slice(tailStart);
|
|
108
182
|
|
|
109
183
|
if (opts.preserveImportant === false) {
|
|
110
184
|
return {
|
|
111
185
|
head,
|
|
112
186
|
tail,
|
|
113
187
|
importantLines: [],
|
|
114
|
-
baseDropped: value.length -
|
|
188
|
+
baseDropped: value.length - headTailBudget,
|
|
115
189
|
};
|
|
116
190
|
}
|
|
117
191
|
|
|
118
|
-
const slackFactor = opts.slackFactor ??
|
|
192
|
+
const slackFactor = opts.slackFactor ?? DEFAULT_SLACK_FACTOR;
|
|
119
193
|
const slackChars = Math.max(0, Math.floor(maxChars * slackFactor));
|
|
120
|
-
const maxCandidates = opts.maxImportantLines ??
|
|
121
|
-
const middle = value.slice(
|
|
194
|
+
const maxCandidates = opts.maxImportantLines ?? DEFAULT_MAX_IMPORTANT_LINES;
|
|
195
|
+
const middle = value.slice(headEnd, tailStart);
|
|
122
196
|
const candidates = extractImportantLines(middle, maxCandidates);
|
|
123
197
|
|
|
124
198
|
// Greedily pick whole lines that fit in the slack budget.
|
|
@@ -135,6 +209,6 @@ export function splitWithImportantLines(value: string, maxChars: number, opts: S
|
|
|
135
209
|
head,
|
|
136
210
|
tail,
|
|
137
211
|
importantLines: chosen,
|
|
138
|
-
baseDropped: value.length -
|
|
212
|
+
baseDropped: value.length - headTailBudget,
|
|
139
213
|
};
|
|
140
214
|
}
|
|
@@ -39,6 +39,14 @@ export interface ToolResultEntry {
|
|
|
39
39
|
content: string;
|
|
40
40
|
/** Whether the tool result represents an error. */
|
|
41
41
|
isError?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* K8: the tool's REAL exit code when the original result carries one
|
|
44
|
+
* (e.g. a bash exit status). Passed through to the digest verbatim
|
|
45
|
+
* instead of the historical `isError ? 1 : 0` guess — exit 127 digests
|
|
46
|
+
* as `exit=127`. Optional: callers whose source shape has no exit code
|
|
47
|
+
* omit it and keep the legacy guess.
|
|
48
|
+
*/
|
|
49
|
+
exitCode?: number;
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
/** A file mutation event (edit/write) that can invalidate earlier reads. */
|
|
@@ -102,11 +110,23 @@ function estimateTokens(text: string): number {
|
|
|
102
110
|
|
|
103
111
|
const DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER = 1.25;
|
|
104
112
|
|
|
105
|
-
function firstErrorLine(text: string): string | undefined {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
function firstErrorLine(text: string, isError = false): string | undefined {
|
|
114
|
+
const lines = text.split(/\r?\n/);
|
|
115
|
+
const matched = lines.find((line) => /error|failed|exception|panic/i.test(line))?.trim();
|
|
116
|
+
if (matched !== undefined) return matched;
|
|
117
|
+
// K8: multi-line stderr/stack whose HEAD carries the diagnostic but no
|
|
118
|
+
// line matches the keyword patterns (localized messages, shell errors,
|
|
119
|
+
// stack frames without a keyword). Fall back to the first 3 non-empty
|
|
120
|
+
// lines of the content head — the head names the failing frame; the old
|
|
121
|
+
// digest kept only the TAIL line, which for a stack trace is the least
|
|
122
|
+
// informative frame. Gated on isError so success output never grows a
|
|
123
|
+
// bogus error= part.
|
|
124
|
+
if (!isError) return undefined;
|
|
125
|
+
const head = lines
|
|
126
|
+
.map((line) => line.trim())
|
|
127
|
+
.filter((line) => line.length > 0)
|
|
128
|
+
.slice(0, 3);
|
|
129
|
+
return head.length > 0 ? head.join(" | ") : undefined;
|
|
110
130
|
}
|
|
111
131
|
|
|
112
132
|
function truncateField(value: string, maxLength: number): string {
|
|
@@ -119,22 +139,26 @@ function truncateField(value: string, maxLength: number): string {
|
|
|
119
139
|
* Generate a compact digest of a tool result for the digest notice.
|
|
120
140
|
* Supports bash (exit code + tail line), grep/search (match/file counts),
|
|
121
141
|
* and falls back to undefined for tools without a known digest format.
|
|
142
|
+
*
|
|
143
|
+
* K8: `exitCode` passes the REAL exit status through when the original
|
|
144
|
+
* result carries one (exit 127 → `exit=127`, not the hardcoded `exit=1`);
|
|
145
|
+
* when absent the legacy `isError ? 1 : 0` guess is kept.
|
|
122
146
|
*/
|
|
123
|
-
export function resultDigest(toolName: string, content: string, isError?: boolean): string | undefined {
|
|
147
|
+
export function resultDigest(toolName: string, content: string, isError?: boolean, exitCode?: number): string | undefined {
|
|
124
148
|
const name = toolName.toLowerCase();
|
|
125
149
|
const text = content ?? "";
|
|
126
150
|
if (name === "bash") {
|
|
127
|
-
const
|
|
151
|
+
const exitCodeValue = typeof exitCode === "number" && Number.isFinite(exitCode) ? Math.trunc(exitCode) : isError ? 1 : 0;
|
|
128
152
|
const tail = text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
|
|
129
|
-
const error = firstErrorLine(text);
|
|
130
|
-
return [`exit=${
|
|
153
|
+
const error = firstErrorLine(text, isError);
|
|
154
|
+
return [`exit=${exitCodeValue}`, tail ? `tail=${tail}` : undefined, error ? `error=${error}` : undefined]
|
|
131
155
|
.filter((part): part is string => part !== undefined)
|
|
132
156
|
.join("; ");
|
|
133
157
|
}
|
|
134
158
|
if (name === "search" || name === "grep") {
|
|
135
159
|
const match = text.match(/(\d+)\s+matches?/i) ?? text.match(/totalMatches["']?:\s*(\d+)/i);
|
|
136
160
|
const files = text.match(/(\d+)\s+files?/i) ?? text.match(/filesWithMatches["']?:\s*(\d+)/i);
|
|
137
|
-
const error = firstErrorLine(text);
|
|
161
|
+
const error = firstErrorLine(text, isError);
|
|
138
162
|
return (
|
|
139
163
|
[match ? `matches=${match[1]}` : undefined, files ? `files=${files[1]}` : undefined, error ? `error=${error}` : undefined]
|
|
140
164
|
.filter((part): part is string => part !== undefined)
|
|
@@ -146,7 +170,7 @@ export function resultDigest(toolName: string, content: string, isError?: boolea
|
|
|
146
170
|
|
|
147
171
|
function createPrunedNotice(tokens: number, entry: ToolResultEntry): string {
|
|
148
172
|
const generic = `[Output pruned — ${tokens} tokens]`;
|
|
149
|
-
const digest = resultDigest(entry.toolName, entry.content, entry.isError);
|
|
173
|
+
const digest = resultDigest(entry.toolName, entry.content, entry.isError, entry.exitCode);
|
|
150
174
|
if (!digest) return generic;
|
|
151
175
|
const genericTokens = countTokens(generic);
|
|
152
176
|
const maxTokens = Math.max(genericTokens, Math.floor(genericTokens * DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER));
|
|
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import {
|
|
5
|
+
atomicWriteFile,
|
|
5
6
|
atomicWriteJson,
|
|
6
7
|
atomicWriteJsonCoalesced,
|
|
7
8
|
cancelPendingCoalescedWriteForPath,
|
|
@@ -686,12 +687,32 @@ function readSeqFromSidecar(filePath: string): number | undefined {
|
|
|
686
687
|
|
|
687
688
|
function writeSeqToSidecar(filePath: string, seq: number): void {
|
|
688
689
|
try {
|
|
689
|
-
|
|
690
|
+
// D6 (DR-02): tmp+rename so a concurrent reader (nextAgentEventSeq on
|
|
691
|
+
// another thread/process) never observes a torn or empty .seq.
|
|
692
|
+
// best-effort durability per F4: the sidecar is only an O(1) lookup
|
|
693
|
+
// optimization — losing it on a crash costs the O(n) scan fallback,
|
|
694
|
+
// never correctness. Path and content format unchanged (String(seq)).
|
|
695
|
+
atomicWriteFile(`${filePath}.${AGENT_EVENT_SEQ_SIDECAR}`, String(seq), { durability: "best-effort" });
|
|
690
696
|
} catch (error) {
|
|
691
697
|
logInternalError("crew-agent-records.seq-sidecar", error, `filePath=${filePath}`);
|
|
692
698
|
}
|
|
693
699
|
}
|
|
694
700
|
|
|
701
|
+
function scanMaxAgentEventSeq(filePath: string): number {
|
|
702
|
+
let max = 0;
|
|
703
|
+
for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
|
|
704
|
+
if (!line.trim()) continue;
|
|
705
|
+
try {
|
|
706
|
+
const parsed = JSON.parse(line) as { seq?: unknown };
|
|
707
|
+
if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) max = Math.max(max, parsed.seq);
|
|
708
|
+
else max += 1;
|
|
709
|
+
} catch {
|
|
710
|
+
max += 1;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
return max;
|
|
714
|
+
}
|
|
715
|
+
|
|
695
716
|
function nextAgentEventSeq(filePath: string): number {
|
|
696
717
|
if (!fs.existsSync(filePath)) {
|
|
697
718
|
// Clean up stale sidecar when main file is gone.
|
|
@@ -708,24 +729,21 @@ function nextAgentEventSeq(filePath: string): number {
|
|
|
708
729
|
// FIX: Try sidecar file for O(1) lookup before falling back to O(n) scan.
|
|
709
730
|
const sidecarSeq = readSeqFromSidecar(filePath);
|
|
710
731
|
if (sidecarSeq !== undefined) {
|
|
732
|
+
// FS1 (fix, EL-1 port from event-log sequence-cache.ts): a REGRESSED
|
|
733
|
+
// sidecar (crash between append and sidecar advance, or lost best-effort
|
|
734
|
+
// durability) must never re-mint used seqs — trust it only as
|
|
735
|
+
// max(sidecar, tail-scan), else cursor consumers (seq > sinceSeq)
|
|
736
|
+
// permanently drop the duplicate-seq events.
|
|
737
|
+
const safeSeq = Math.max(sidecarSeq, scanMaxAgentEventSeq(filePath));
|
|
711
738
|
setAgentEventSeqCache(filePath, {
|
|
712
739
|
size: stat.size,
|
|
713
740
|
mtimeMs: stat.mtimeMs,
|
|
714
|
-
seq:
|
|
741
|
+
seq: safeSeq,
|
|
715
742
|
});
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
let max = 0;
|
|
719
|
-
for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
|
|
720
|
-
if (!line.trim()) continue;
|
|
721
|
-
try {
|
|
722
|
-
const parsed = JSON.parse(line) as { seq?: unknown };
|
|
723
|
-
if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) max = Math.max(max, parsed.seq);
|
|
724
|
-
else max += 1;
|
|
725
|
-
} catch {
|
|
726
|
-
max += 1;
|
|
727
|
-
}
|
|
743
|
+
if (safeSeq !== sidecarSeq) writeSeqToSidecar(filePath, safeSeq);
|
|
744
|
+
return safeSeq + 1;
|
|
728
745
|
}
|
|
746
|
+
const max = scanMaxAgentEventSeq(filePath);
|
|
729
747
|
setAgentEventSeqCache(filePath, {
|
|
730
748
|
size: stat.size,
|
|
731
749
|
mtimeMs: stat.mtimeMs,
|
|
@@ -26,6 +26,12 @@ export interface DeliveryCoordinatorDeps {
|
|
|
26
26
|
const PENDING_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
27
27
|
|
|
28
28
|
export class DeliveryCoordinator {
|
|
29
|
+
// DR-02 C3: the queue was TTL-bounded ONLY (24h) — an extension that stays
|
|
30
|
+
// sessionless while runs keep completing could grow `pending` unboundedly.
|
|
31
|
+
// Hard count cap with drop-oldest (oldest delivery is the least likely to
|
|
32
|
+
// still matter); the TTL eviction below stays unchanged. Static (not
|
|
33
|
+
// module-const) so tests can read/patch it like Semaphore.MAX_QUEUE.
|
|
34
|
+
static readonly MAX_PENDING = 1_000;
|
|
29
35
|
private active = false;
|
|
30
36
|
private generation = 0;
|
|
31
37
|
private pending: PendingDelivery[] = [];
|
|
@@ -36,6 +42,9 @@ export class DeliveryCoordinator {
|
|
|
36
42
|
/** The session id passed to the most recent activate(); used by flushQueuedResults
|
|
37
43
|
* to park deliveries owned by a different session (vector #12). */
|
|
38
44
|
private activeSessionId?: string;
|
|
45
|
+
/** DR-02 C3: set after the first warn of an overflow episode; cleared once
|
|
46
|
+
* the queue is back at/below the cap so the NEXT episode warns again. */
|
|
47
|
+
private pendingOverflowWarned = false;
|
|
39
48
|
|
|
40
49
|
constructor(deps: DeliveryCoordinatorDeps) {
|
|
41
50
|
this.deps = deps;
|
|
@@ -215,6 +224,27 @@ export class DeliveryCoordinator {
|
|
|
215
224
|
this.ttlTimer.unref();
|
|
216
225
|
}
|
|
217
226
|
this.pending.push({ ...delivery, generation: this.generation });
|
|
227
|
+
// DR-02 C3: count cap — drop-oldest while over cap, warn ONCE per
|
|
228
|
+
// overflow episode (flag resets once the queue is back under cap, so a
|
|
229
|
+
// later separate overflow is visible again).
|
|
230
|
+
if (this.pending.length <= DeliveryCoordinator.MAX_PENDING) {
|
|
231
|
+
this.pendingOverflowWarned = false;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
let dropped = 0;
|
|
235
|
+
while (this.pending.length > DeliveryCoordinator.MAX_PENDING) {
|
|
236
|
+
if (this.pending.shift() === undefined) break;
|
|
237
|
+
dropped++;
|
|
238
|
+
}
|
|
239
|
+
if (dropped > 0 && !this.pendingOverflowWarned) {
|
|
240
|
+
this.pendingOverflowWarned = true;
|
|
241
|
+
logInternalError(
|
|
242
|
+
"delivery-coordinator.pending-overflow",
|
|
243
|
+
undefined,
|
|
244
|
+
`dropped=${dropped} max=${DeliveryCoordinator.MAX_PENDING} remaining=${this.pending.length} runId=${delivery.runId}`,
|
|
245
|
+
"warn",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
218
248
|
}
|
|
219
249
|
|
|
220
250
|
private evictExpired(): void {
|
|
@@ -202,12 +202,15 @@ export async function sweepExpiredWaitingTasks(
|
|
|
202
202
|
}
|
|
203
203
|
try {
|
|
204
204
|
const existingRuntimes = new Map(readCrewAgents(fresh.manifest).map((a) => [a.taskId, a.runtime]));
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
205
|
+
// R4-A1 (fix): agents.json is a full-index overwrite — merge the requeued
|
|
206
|
+
// records into the existing set (team-runner.ts:525 pattern) instead of
|
|
207
|
+
// persisting the subset alone (siblings incl. RUNNING agents were wiped).
|
|
208
|
+
const mergedById = new Map(readCrewAgents(fresh.manifest).map((record) => [record.id, record] as const));
|
|
209
|
+
for (const task of tasks.filter((t) => requeuedTaskIds.includes(t.id))) {
|
|
210
|
+
const record = recordFromTask(fresh.manifest, task, existingRuntimes.get(task.id) ?? "child-process");
|
|
211
|
+
mergedById.set(record.id, record);
|
|
212
|
+
}
|
|
213
|
+
saveCrewAgents(fresh.manifest, [...mergedById.values()]);
|
|
211
214
|
} catch (error) {
|
|
212
215
|
logInternalError("dispatch-batch.ask-timeout.crewAgents", error, `runId=${runId}`);
|
|
213
216
|
}
|