pi-crew 0.9.8 → 0.9.10
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 +311 -0
- package/README.md +2 -2
- package/docs/fixes/v0.9.10/locks-fix-verify.md +3 -0
- package/docs/fixes/v0.9.10/smoke-test.md +12 -0
- package/package.json +1 -1
- package/src/extension/register.ts +94 -21
- package/src/extension/registration/subagent-helpers.ts +1 -0
- package/src/extension/registration/subagent-tools.ts +9 -0
- package/src/extension/team-tool/doctor.ts +41 -18
- package/src/runtime/batch-barrier.ts +145 -0
- package/src/runtime/child-pi.ts +135 -22
- package/src/runtime/compact-pipeline.ts +56 -0
- package/src/runtime/compact-stages/ansi-strip-stage.ts +25 -0
- package/src/runtime/compact-stages/blank-collapse-stage.ts +31 -0
- package/src/runtime/compact-stages/deduplicate-stage.ts +34 -0
- package/src/runtime/compact-stages/head-snap-stage.ts +57 -0
- package/src/runtime/compact-stages/index.ts +13 -0
- package/src/runtime/compact-stages/tail-capture-stage.ts +72 -0
- package/src/runtime/compact-stages/truncation-stage.ts +71 -0
- package/src/runtime/crash-classification.ts +208 -0
- package/src/runtime/custom-tools/irc-tool.ts +47 -7
- package/src/runtime/handoff-manager.ts +10 -0
- package/src/runtime/important-line-classifier.ts +130 -0
- package/src/runtime/iteration-hooks.ts +7 -19
- package/src/runtime/live-agent-manager.ts +185 -0
- package/src/runtime/live-session-runtime.ts +50 -1
- package/src/runtime/model-fallback.ts +29 -1
- package/src/runtime/process-lifecycle.ts +481 -0
- package/src/runtime/role-permission.ts +2 -2
- package/src/runtime/stream-preview.ts +9 -2
- package/src/runtime/subagent-manager.ts +6 -0
- package/src/runtime/task-output-context.ts +209 -24
- package/src/runtime/task-runner.ts +76 -15
- package/src/runtime/tool-output-pruner.ts +334 -0
- package/src/state/locks.ts +16 -0
- package/src/state/state-store.ts +8 -2
- package/src/state/types.ts +5 -0
- package/src/ui/live-run-sidebar.ts +6 -1
- package/src/ui/loaders.ts +24 -4
- package/src/ui/run-dashboard.ts +6 -1
- package/src/ui/run-event-bus.ts +1 -1
- package/src/ui/run-snapshot-cache.ts +50 -16
- package/src/ui/widget/index.ts +27 -5
- package/src/ui/widget/widget-renderer.ts +43 -13
- package/src/utils/redaction.ts +17 -1
- package/src/utils/visual.ts +6 -0
- package/src/ui/crew-widget.ts +0 -544
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staleness-aware tool output pruning.
|
|
3
|
+
*
|
|
4
|
+
* Identifies tool results that have been superseded by a later result for the
|
|
5
|
+
* same target (same file read again, same search re-run) or invalidated by a
|
|
6
|
+
* later successful edit/write to a covered file, and replaces the stale
|
|
7
|
+
* content with a compact digest notice. Protect-window and minimum-savings
|
|
8
|
+
* hysteresis ensure recent results are preserved and pruning only fires when
|
|
9
|
+
* the savings justify it.
|
|
10
|
+
*
|
|
11
|
+
* Ported and adapted from gajae-code's compaction/pruning.ts to pi-crew's
|
|
12
|
+
* data shapes. Pi-crew delegates conversation management to child Pi
|
|
13
|
+
* processes, so this module operates on a generic {@link ToolResultEntry}
|
|
14
|
+
* sequence rather than SessionEntry[]. The primary integration point is
|
|
15
|
+
* task-output-context.ts (dependency output context injected into worker
|
|
16
|
+
* prompts), but the module is designed to be reusable for any in-process
|
|
17
|
+
* tool-result sequence.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A single tool result in a sequence (oldest → newest).
|
|
26
|
+
* Adapted to pi-crew's shapes — does not depend on gajae-code's SessionEntry.
|
|
27
|
+
*/
|
|
28
|
+
export interface ToolResultEntry {
|
|
29
|
+
/** Stable identifier for deduplication and correlation. */
|
|
30
|
+
id: string;
|
|
31
|
+
/** Tool name: "read", "bash", "grep", "search", "edit", "write", etc. */
|
|
32
|
+
toolName: string;
|
|
33
|
+
/**
|
|
34
|
+
* Target identity: file path for read/edit/write, search pattern for
|
|
35
|
+
* grep/search, or undefined for tools without a natural target key.
|
|
36
|
+
*/
|
|
37
|
+
target?: string;
|
|
38
|
+
/** The tool result content text. */
|
|
39
|
+
content: string;
|
|
40
|
+
/** Whether the tool result represents an error. */
|
|
41
|
+
isError?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A file mutation event (edit/write) that can invalidate earlier reads. */
|
|
45
|
+
export interface FileEditEvent {
|
|
46
|
+
/** The file path that was mutated. */
|
|
47
|
+
target: string;
|
|
48
|
+
/**
|
|
49
|
+
* Sequence index of this edit relative to tool results. A read at index
|
|
50
|
+
* `i` is stale if an edit at index `j > i` touches the same file.
|
|
51
|
+
*/
|
|
52
|
+
index: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface PruneConfig {
|
|
56
|
+
/** Keep the most recent tool output tokens intact (protect window). */
|
|
57
|
+
protectTokens: number;
|
|
58
|
+
/** Only prune if total savings meets this threshold (hysteresis). */
|
|
59
|
+
minimumSavings: number;
|
|
60
|
+
/** Tool names that should never be pruned. */
|
|
61
|
+
protectedTools: string[];
|
|
62
|
+
/**
|
|
63
|
+
* Tools in `protectedTools` whose protection is waived once the result is
|
|
64
|
+
* superseded (a later result for the same target, or a later successful
|
|
65
|
+
* edit/write to the covered file). The most recent result per target is
|
|
66
|
+
* never considered superseded. Optional; defaults to none.
|
|
67
|
+
*/
|
|
68
|
+
staleOverridableTools?: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
|
|
72
|
+
protectTokens: 40_000,
|
|
73
|
+
minimumSavings: 20_000,
|
|
74
|
+
protectedTools: ["read"],
|
|
75
|
+
staleOverridableTools: ["read"],
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export interface PruneResult {
|
|
79
|
+
/** Number of entries pruned. */
|
|
80
|
+
prunedCount: number;
|
|
81
|
+
/** Estimated tokens saved. */
|
|
82
|
+
tokensSaved: number;
|
|
83
|
+
/** The pruned result entries (same length as input, content replaced for pruned). */
|
|
84
|
+
results: ToolResultEntry[];
|
|
85
|
+
/** IDs of entries that were pruned. */
|
|
86
|
+
prunedIds: string[];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Token estimation (rough char/4 heuristic, matching gajae-code)
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
function estimateTokens(text: string): number {
|
|
94
|
+
return Math.ceil(text.length / 4);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Digest notice generation
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
const DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER = 1.25;
|
|
102
|
+
|
|
103
|
+
function firstErrorLine(text: string): string | undefined {
|
|
104
|
+
return text
|
|
105
|
+
.split(/\r?\n/)
|
|
106
|
+
.find((line) => /error|failed|exception|panic/i.test(line))
|
|
107
|
+
?.trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function truncateField(value: string, maxLength: number): string {
|
|
111
|
+
if (value.length <= maxLength) return value;
|
|
112
|
+
if (maxLength <= 1) return "…";
|
|
113
|
+
return `${value.slice(0, maxLength - 1)}…`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Generate a compact digest of a tool result for the digest notice.
|
|
118
|
+
* Supports bash (exit code + tail line), grep/search (match/file counts),
|
|
119
|
+
* and falls back to undefined for tools without a known digest format.
|
|
120
|
+
*/
|
|
121
|
+
export function resultDigest(toolName: string, content: string, isError?: boolean): string | undefined {
|
|
122
|
+
const name = toolName.toLowerCase();
|
|
123
|
+
const text = content ?? "";
|
|
124
|
+
if (name === "bash") {
|
|
125
|
+
const exitCode = isError ? 1 : 0;
|
|
126
|
+
const tail = text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
|
|
127
|
+
const error = firstErrorLine(text);
|
|
128
|
+
return [`exit=${exitCode}`, tail ? `tail=${tail}` : undefined, error ? `error=${error}` : undefined]
|
|
129
|
+
.filter((part): part is string => part !== undefined)
|
|
130
|
+
.join("; ");
|
|
131
|
+
}
|
|
132
|
+
if (name === "search" || name === "grep") {
|
|
133
|
+
const match = text.match(/(\d+)\s+matches?/i) ?? text.match(/totalMatches["']?:\s*(\d+)/i);
|
|
134
|
+
const files = text.match(/(\d+)\s+files?/i) ?? text.match(/filesWithMatches["']?:\s*(\d+)/i);
|
|
135
|
+
const error = firstErrorLine(text);
|
|
136
|
+
return (
|
|
137
|
+
[
|
|
138
|
+
match ? `matches=${match[1]}` : undefined,
|
|
139
|
+
files ? `files=${files[1]}` : undefined,
|
|
140
|
+
error ? `error=${error}` : undefined,
|
|
141
|
+
]
|
|
142
|
+
.filter((part): part is string => part !== undefined)
|
|
143
|
+
.join("; ") || "search digest unavailable"
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function createPrunedNotice(tokens: number, entry: ToolResultEntry): string {
|
|
150
|
+
const generic = `[Output pruned — ${tokens} tokens]`;
|
|
151
|
+
const digest = resultDigest(entry.toolName, entry.content, entry.isError);
|
|
152
|
+
if (!digest) return generic;
|
|
153
|
+
const genericTokens = Math.ceil(generic.length / 4);
|
|
154
|
+
const maxTokens = Math.max(genericTokens, Math.floor(genericTokens * DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER));
|
|
155
|
+
const prefix = `[Output pruned — ${tokens} tokens; `;
|
|
156
|
+
const suffix = "]";
|
|
157
|
+
const maxChars = Math.max(0, maxTokens * 4 - prefix.length - suffix.length);
|
|
158
|
+
return `${prefix}${truncateField(digest, maxChars)}${suffix}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Target key resolution
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Trailing read selectors (`:50`, `:50-200`, `:50+150`, `:5-16,960-973`,
|
|
167
|
+
* `:raw`, `:conflicts`), possibly stacked. Stripped to resolve the
|
|
168
|
+
* underlying file for edit invalidation.
|
|
169
|
+
*/
|
|
170
|
+
const READ_SELECTOR_SUFFIX = /:(?:raw|conflicts|\d+(?:[-+]\d+)?(?:,\d+(?:[-+]\d+)?)*)$/;
|
|
171
|
+
|
|
172
|
+
/** Base file path of a read target with any line/mode selectors stripped. */
|
|
173
|
+
function readBasePath(filePath: string): string {
|
|
174
|
+
let base = filePath;
|
|
175
|
+
while (READ_SELECTOR_SUFFIX.test(base)) {
|
|
176
|
+
base = base.replace(READ_SELECTOR_SUFFIX, "");
|
|
177
|
+
}
|
|
178
|
+
return base;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Stable identity for "the same logical lookup": same tool re-targeting the
|
|
183
|
+
* same subject. A later result with the same key supersedes earlier ones.
|
|
184
|
+
*/
|
|
185
|
+
function toolTargetKey(entry: ToolResultEntry): string | undefined {
|
|
186
|
+
if (!entry.target || entry.target.length === 0) return undefined;
|
|
187
|
+
return JSON.stringify([entry.toolName, entry.target]);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Staleness index
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export interface StalenessIndex {
|
|
195
|
+
/** Indices of tool results superseded by a later same-target result or edit. */
|
|
196
|
+
staleIndices: Set<number>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Build a staleness index over a sequence of tool results (oldest → newest):
|
|
201
|
+
* - a tool result is stale when a later non-error result shares its target key;
|
|
202
|
+
* - a `read` result is stale when a later edit event touches its file.
|
|
203
|
+
* The most recent result per target is never stale.
|
|
204
|
+
*
|
|
205
|
+
* @param toolResults Ordered tool result entries (oldest first).
|
|
206
|
+
* @param fileEdits Optional file mutation events with sequence indices.
|
|
207
|
+
*/
|
|
208
|
+
export function buildStalenessIndex(toolResults: ToolResultEntry[], fileEdits: FileEditEvent[] = []): StalenessIndex {
|
|
209
|
+
// Map target key → last result index that has it.
|
|
210
|
+
const lastResultIndexByKey = new Map<string, number>();
|
|
211
|
+
for (let i = 0; i < toolResults.length; i++) {
|
|
212
|
+
const entry = toolResults[i]!;
|
|
213
|
+
if (entry.isError) continue;
|
|
214
|
+
const key = toolTargetKey(entry);
|
|
215
|
+
if (key !== undefined) lastResultIndexByKey.set(key, i);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Map file path → last edit index.
|
|
219
|
+
const lastEditIndexByPath = new Map<string, number>();
|
|
220
|
+
for (const edit of fileEdits) {
|
|
221
|
+
lastEditIndexByPath.set(edit.target, edit.index);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const staleIndices = new Set<number>();
|
|
225
|
+
for (let i = 0; i < toolResults.length; i++) {
|
|
226
|
+
const entry = toolResults[i]!;
|
|
227
|
+
// Check superseded by same-target re-read.
|
|
228
|
+
const key = toolTargetKey(entry);
|
|
229
|
+
if (key !== undefined) {
|
|
230
|
+
const lastIndex = lastResultIndexByKey.get(key);
|
|
231
|
+
if (lastIndex !== undefined && lastIndex > i) {
|
|
232
|
+
staleIndices.add(i);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Check invalidated by later file edit (read-specific).
|
|
237
|
+
if (entry.toolName.toLowerCase() === "read" && entry.target) {
|
|
238
|
+
const basePath = readBasePath(entry.target);
|
|
239
|
+
const editIndex = lastEditIndexByPath.get(basePath);
|
|
240
|
+
if (editIndex !== undefined && editIndex > i) {
|
|
241
|
+
staleIndices.add(i);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { staleIndices };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Pruning
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Prune stale tool outputs from a sequence, replacing superseded content with
|
|
255
|
+
* compact digest notices. Protect-window, protected-tools immunity, and
|
|
256
|
+
* minimum-savings hysteresis are all respected.
|
|
257
|
+
*
|
|
258
|
+
* OPT-IN by default: {@link DEFAULT_PRUNE_CONFIG} protects recent results via
|
|
259
|
+
* a generous `protectTokens` window. Only results outside the window AND not
|
|
260
|
+
* protected AND stale (or old enough) are pruned.
|
|
261
|
+
*
|
|
262
|
+
* @param results Ordered tool result entries (oldest first).
|
|
263
|
+
* @param config Prune configuration. Defaults to {@link DEFAULT_PRUNE_CONFIG}.
|
|
264
|
+
*/
|
|
265
|
+
export function pruneToolOutputs(results: ToolResultEntry[], config: PruneConfig = DEFAULT_PRUNE_CONFIG): PruneResult {
|
|
266
|
+
const { staleIndices } = buildStalenessIndex(results);
|
|
267
|
+
const staleOverridable = new Set(config.staleOverridableTools ?? []);
|
|
268
|
+
|
|
269
|
+
let accumulatedTokens = 0;
|
|
270
|
+
let tokensSaved = 0;
|
|
271
|
+
let prunedCount = 0;
|
|
272
|
+
|
|
273
|
+
interface Candidate {
|
|
274
|
+
index: number;
|
|
275
|
+
entry: ToolResultEntry;
|
|
276
|
+
tokens: number;
|
|
277
|
+
notice: string;
|
|
278
|
+
savings: number;
|
|
279
|
+
}
|
|
280
|
+
const candidates: Candidate[] = [];
|
|
281
|
+
const prunedIds: string[] = [];
|
|
282
|
+
|
|
283
|
+
// Iterate newest → oldest to accumulate the protect window from the tail.
|
|
284
|
+
for (let i = results.length - 1; i >= 0; i--) {
|
|
285
|
+
const entry = results[i]!;
|
|
286
|
+
const tokens = estimateTokens(entry.content);
|
|
287
|
+
const isStale = staleIndices.has(i);
|
|
288
|
+
|
|
289
|
+
// Staleness waives protected-tool immunity for overridable tools
|
|
290
|
+
// (e.g. a superseded `read`); the most recent result per target is
|
|
291
|
+
// never stale, so the latest read of each file stays protected.
|
|
292
|
+
const isProtected =
|
|
293
|
+
config.protectedTools.includes(entry.toolName) &&
|
|
294
|
+
!(isStale && staleOverridable.has(entry.toolName));
|
|
295
|
+
|
|
296
|
+
// Stale results are prunable even inside the recency protect window —
|
|
297
|
+
// they are superseded, so recency no longer implies relevance. They
|
|
298
|
+
// still count toward window accounting so non-stale protection is
|
|
299
|
+
// unchanged.
|
|
300
|
+
const insideProtectWindow = accumulatedTokens < config.protectTokens;
|
|
301
|
+
if ((insideProtectWindow && !isStale) || isProtected) {
|
|
302
|
+
accumulatedTokens += tokens;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const notice = createPrunedNotice(tokens, entry);
|
|
307
|
+
candidates.push({
|
|
308
|
+
index: i,
|
|
309
|
+
entry,
|
|
310
|
+
tokens,
|
|
311
|
+
notice,
|
|
312
|
+
savings: Math.max(0, tokens - Math.ceil(notice.length / 4)),
|
|
313
|
+
});
|
|
314
|
+
accumulatedTokens += tokens;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
for (const candidate of candidates) {
|
|
318
|
+
tokensSaved += candidate.savings;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Hysteresis: only prune if savings meet the threshold.
|
|
322
|
+
if (tokensSaved < config.minimumSavings || candidates.length === 0) {
|
|
323
|
+
return { prunedCount: 0, tokensSaved: 0, results, prunedIds: [] };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const prunedResults = [...results];
|
|
327
|
+
for (const candidate of candidates) {
|
|
328
|
+
prunedResults[candidate.index] = { ...candidate.entry, content: candidate.notice };
|
|
329
|
+
prunedIds.push(candidate.entry.id);
|
|
330
|
+
prunedCount++;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return { prunedCount, tokensSaved, results: prunedResults, prunedIds };
|
|
334
|
+
}
|
package/src/state/locks.ts
CHANGED
|
@@ -292,6 +292,17 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
|
|
|
292
292
|
// append, or even the lock acquisition itself) would race with the lock.
|
|
293
293
|
const lockFile = `${filePath}.lock`;
|
|
294
294
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
295
|
+
// FIX (Round 29): re-entrance guard — mirrors withRunLockSync below.
|
|
296
|
+
// When the same call stack already holds the file lock (e.g.
|
|
297
|
+
// registerWorker -> cleanupOrphanWorkers -> readRegistry), the second
|
|
298
|
+
// acquisition would otherwise read its own freshly-written lock file
|
|
299
|
+
// (same pid, fresh createdAt), fail the steal check, and deadlock for
|
|
300
|
+
// the full staleMs window. Strace-confirmed in
|
|
301
|
+
// .github/issues/pre-existing-2026-06-10/04-orphan-worker-registry-tests.md:75-86.
|
|
302
|
+
const existingToken = fileLockHeldByUs.get(lockFile);
|
|
303
|
+
if (existingToken) {
|
|
304
|
+
return fn();
|
|
305
|
+
}
|
|
295
306
|
// FIX: Validate the parent directory is not a symlink BEFORE calling mkdirSync.
|
|
296
307
|
// Between mkdir and lock acquisition, an attacker could plant a symlink.
|
|
297
308
|
if (!isSymlinkSafePath(path.dirname(lockFile))) throw new Error("Refusing: parent of lock directory is a symlink");
|
|
@@ -322,10 +333,12 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
|
|
|
322
333
|
}
|
|
323
334
|
}
|
|
324
335
|
if (token === "") throw new Error(`Run '${path.basename(lockFile)}' is locked by another operation.`);
|
|
336
|
+
fileLockHeldByUs.set(lockFile, token);
|
|
325
337
|
try {
|
|
326
338
|
return fn();
|
|
327
339
|
} finally {
|
|
328
340
|
// Token-guarded release: don't rm the lock if it has been stolen.
|
|
341
|
+
fileLockHeldByUs.delete(lockFile);
|
|
329
342
|
releaseLock(lockFile, token);
|
|
330
343
|
}
|
|
331
344
|
}
|
|
@@ -353,6 +366,9 @@ export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, optio
|
|
|
353
366
|
// already held by this call stack (handleResume -> executeTeamRun ->
|
|
354
367
|
// executeTeamRunCore), we skip re-acquisition to avoid deadlock.
|
|
355
368
|
const runLockHeldByUs = new Map<string, string>(); // filePath -> token
|
|
369
|
+
// Round 29: parallel map for withFileLockSync re-entrance. See the comment
|
|
370
|
+
// at the top of withFileLockSync for the full deadlock mechanism.
|
|
371
|
+
const fileLockHeldByUs = new Map<string, string>(); // lockFile -> token
|
|
356
372
|
|
|
357
373
|
export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promise<T>, options: RunLockOptions = {}): Promise<T> {
|
|
358
374
|
const filePath = lockPath(manifest);
|
package/src/state/state-store.ts
CHANGED
|
@@ -634,7 +634,10 @@ export function loadRunManifestById(cwd: string, runId: string): { manifest: Tea
|
|
|
634
634
|
// between the final stat and the read. Callers needing strict consistency
|
|
635
635
|
// MUST use withRunLock() around load+modify+save.
|
|
636
636
|
if (attempts > 0) {
|
|
637
|
-
|
|
637
|
+
// Round 19: downgrade to debug — retry-loop instability is expected under
|
|
638
|
+
// concurrent writes (live team runs constantly append to tasks.json).
|
|
639
|
+
// This is best-effort by design; strict consistency requires withRunLock().
|
|
640
|
+
console.debug(`[state-store] loadRunManifestById: retry loop detected instability for run ${runId} after ${attempts} attempt(s) — best-effort only, use withRunLock() for strict consistency`);
|
|
638
641
|
}
|
|
639
642
|
// NOTE: manifest mtime may legitimately be >= tasks mtime because
|
|
640
643
|
// saveManifestAndTasksAtomicSync writes manifest before tasks. However,
|
|
@@ -724,7 +727,10 @@ export async function loadRunManifestByIdAsync(cwd: string, runId: string): Prom
|
|
|
724
727
|
// between the final stat and the read. Callers needing strict consistency
|
|
725
728
|
// MUST use withRunLock() around load+modify+save.
|
|
726
729
|
if (attempts > 0) {
|
|
727
|
-
|
|
730
|
+
// Round 19: downgrade to debug — retry-loop instability is expected under
|
|
731
|
+
// concurrent writes (live team runs constantly append to tasks.json).
|
|
732
|
+
// This is best-effort by design; strict consistency requires withRunLock().
|
|
733
|
+
console.debug(`[state-store] loadRunManifestByIdAsync: retry loop detected instability for run ${runId} after ${attempts} attempt(s) — best-effort only, use withRunLock() for strict consistency`);
|
|
728
734
|
}
|
|
729
735
|
// NOTE: manifest mtime may legitimately be >= tasks mtime because
|
|
730
736
|
// saveManifestAndTasksAtomicSync writes manifest before tasks. However,
|
package/src/state/types.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { TaskClaimState } from "./task-claims.ts";
|
|
|
3
3
|
import type { WorkerHeartbeatState } from "../runtime/worker-heartbeat.ts";
|
|
4
4
|
import type { CrewAgentProgress } from "../runtime/crew-agent-runtime.ts";
|
|
5
5
|
import type { RolloutEntry, CoherenceMark } from "./decision-ledger.ts";
|
|
6
|
+
import type { CrashClass } from "../runtime/crash-classification.ts";
|
|
6
7
|
export type { RolloutEntry, CoherenceMark };
|
|
7
8
|
export type { CrewAgentProgress };
|
|
8
9
|
|
|
@@ -116,6 +117,10 @@ export interface WorkerExitStatus {
|
|
|
116
117
|
signal?: string;
|
|
117
118
|
cleanupErrors: string[];
|
|
118
119
|
finalDrainMs: number;
|
|
120
|
+
/** Categorical classification of the exit (P0 crash taxonomy). Optional
|
|
121
|
+
* because it is populated by child-pi.ts at settle time; older/synthetic
|
|
122
|
+
* exit statuses may omit it. */
|
|
123
|
+
crashClass?: CrashClass;
|
|
119
124
|
/** Phase-0 diagnostic (HB-003a): final-drain race state for the exit-null
|
|
120
125
|
* disableTools bug. Optional + read-only — absent when no drain timer was
|
|
121
126
|
* ever armed. Phase 1 will use `finalDrainArmed` to decide whether a
|
|
@@ -76,7 +76,12 @@ export class LiveRunSidebar {
|
|
|
76
76
|
this.config = input.config ?? {};
|
|
77
77
|
this.snapshotCache = input.snapshotCache;
|
|
78
78
|
this.unsubscribeTheme = subscribeThemeChange(input.theme, () => this.invalidate());
|
|
79
|
-
this.unsubscribeEventBus =
|
|
79
|
+
this.unsubscribeEventBus = (() => {
|
|
80
|
+
const unsub1 = runEventBus.onChannel("run:state", () => this.invalidate());
|
|
81
|
+
const unsub2 = runEventBus.onChannel("worker:lifecycle", () => this.invalidate());
|
|
82
|
+
const unsub3 = runEventBus.onChannel("ui:invalidate", () => this.invalidate());
|
|
83
|
+
return () => { unsub1(); unsub2(); unsub3(); };
|
|
84
|
+
})();
|
|
80
85
|
}
|
|
81
86
|
|
|
82
87
|
private buildSignature(manifestStatus: string, tasks: TeamTaskState[], agents: ReturnType<typeof readCrewAgents>, waitingCount: number, snapshot?: RunUiSnapshot): string {
|
package/src/ui/loaders.ts
CHANGED
|
@@ -113,23 +113,43 @@ export class CountdownTimer {
|
|
|
113
113
|
private readonly timeoutMs: number;
|
|
114
114
|
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
115
115
|
private expired = false;
|
|
116
|
+
private lastEmittedSeconds = -1;
|
|
116
117
|
|
|
117
118
|
constructor(options: CountdownTimerOptions) {
|
|
118
119
|
this.timeoutMs = Math.max(0, options.timeoutMs);
|
|
119
120
|
this.onTick = options.onTick;
|
|
120
121
|
this.onExpire = options.onExpire;
|
|
121
122
|
this.startedAt = Date.now();
|
|
122
|
-
this.
|
|
123
|
+
this.lastEmittedSeconds = this.secondsLeft();
|
|
124
|
+
this.onTick(this.lastEmittedSeconds);
|
|
123
125
|
if (this.timeoutMs === 0) {
|
|
124
126
|
this.emitExpire();
|
|
125
127
|
return;
|
|
126
128
|
}
|
|
127
|
-
this.
|
|
129
|
+
this.scheduleNextTick();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Schedule the next tick via recursive setTimeout. Each tick re-emits the
|
|
134
|
+
* current `secondsLeft()` only if it differs from the last emitted value
|
|
135
|
+
* (lastEmittedSeconds guard). This makes the countdown correct even under
|
|
136
|
+
* event-loop pressure: if the previous tick fired 1.2s late, the next
|
|
137
|
+
* tick still emits the right value for the current second rather than
|
|
138
|
+
* skipping it (the pre-fix `setInterval` could SKIP a second value when
|
|
139
|
+
* the loop was busy, producing [3,2,0] instead of [3,2,1,0] in tests).
|
|
140
|
+
*/
|
|
141
|
+
private scheduleNextTick(): void {
|
|
142
|
+
this.timer = setTimeout(() => {
|
|
128
143
|
const seconds = this.secondsLeft();
|
|
129
|
-
this.
|
|
144
|
+
if (seconds !== this.lastEmittedSeconds) {
|
|
145
|
+
this.lastEmittedSeconds = seconds;
|
|
146
|
+
this.onTick(seconds);
|
|
147
|
+
}
|
|
130
148
|
if (seconds <= 0) {
|
|
131
149
|
this.emitExpire();
|
|
150
|
+
return;
|
|
132
151
|
}
|
|
152
|
+
this.scheduleNextTick();
|
|
133
153
|
}, 1000);
|
|
134
154
|
// Defense-in-depth: never let the countdown timer keep the event loop
|
|
135
155
|
// alive. If dispose() is missed (e.g. UI unmount race), the timer must
|
|
@@ -151,7 +171,7 @@ export class CountdownTimer {
|
|
|
151
171
|
|
|
152
172
|
dispose(): void {
|
|
153
173
|
if (this.timer === undefined) return;
|
|
154
|
-
|
|
174
|
+
clearTimeout(this.timer);
|
|
155
175
|
this.timer = undefined;
|
|
156
176
|
}
|
|
157
177
|
}
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -294,7 +294,12 @@ export class RunDashboard implements DashboardComponent {
|
|
|
294
294
|
this.theme = asCrewTheme(theme);
|
|
295
295
|
this.options = options;
|
|
296
296
|
this.unsubscribeTheme = subscribeThemeChange(theme, () => this.invalidateAndRender());
|
|
297
|
-
this.unsubscribeEventBus =
|
|
297
|
+
this.unsubscribeEventBus = (() => {
|
|
298
|
+
const unsub1 = runEventBus.onChannel("run:state", () => this.invalidateAndRender());
|
|
299
|
+
const unsub2 = runEventBus.onChannel("worker:lifecycle", () => this.invalidateAndRender());
|
|
300
|
+
const unsub3 = runEventBus.onChannel("ui:invalidate", () => this.invalidateAndRender());
|
|
301
|
+
return () => { unsub1(); unsub2(); unsub3(); };
|
|
302
|
+
})();
|
|
298
303
|
}
|
|
299
304
|
|
|
300
305
|
/**
|
package/src/ui/run-event-bus.ts
CHANGED
|
@@ -40,7 +40,7 @@ const RUN_STATE_TYPES = new Set([
|
|
|
40
40
|
"manifest.saved", "task.claimed", "task.unclaimed", "mailbox_updated",
|
|
41
41
|
]);
|
|
42
42
|
const UI_INVALIDATE_TYPES = new Set([
|
|
43
|
-
"effectiveness_changed", "snapshot_stale",
|
|
43
|
+
"effectiveness_changed", "snapshot_stale", "run.cache_invalidated",
|
|
44
44
|
]);
|
|
45
45
|
|
|
46
46
|
/** Classify an event type string into a typed channel. */
|
|
@@ -787,11 +787,55 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
|
|
|
787
787
|
}
|
|
788
788
|
}
|
|
789
789
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
790
|
+
// Coalesced eager refresh on event-bus signals. Previously every
|
|
791
|
+
// `run:state` / `worker:lifecycle` event deleted the cache entry, leaving
|
|
792
|
+
// a window where `widget-model.ts: snapshotCache.get(runId)` returned
|
|
793
|
+
// `undefined`. The widget then fell back to `agentsFor(run)` (a disk read
|
|
794
|
+
// with no snapshot.tasks) and rendered the "0/1 done" branch of
|
|
795
|
+
// `widget-renderer.ts:39-41` instead of the "Phase 1/1 default: 0% (0/3)"
|
|
796
|
+
// branch — producing the live flicker between those two progressPart
|
|
797
|
+
// values every render tick. Replacing the delete with a coalesced
|
|
798
|
+
// refreshIfStale keeps the cache populated so the widget always sees the
|
|
799
|
+
// same logical snapshot between stamp changes; multiple events for the
|
|
800
|
+
// same runId within INVAL_COALESCE_MS are batched into one refresh.
|
|
801
|
+
function localRefresh(runId: string): RunUiSnapshot {
|
|
802
|
+
const previous = entries.get(runId);
|
|
803
|
+
const entry = build(runId, previous);
|
|
804
|
+
entries.set(runId, entry);
|
|
805
|
+
evictIfNeeded();
|
|
806
|
+
return entry.snapshot;
|
|
807
|
+
}
|
|
808
|
+
function localRefreshIfStale(runId: string): RunUiSnapshot {
|
|
809
|
+
const previous = entries.get(runId);
|
|
810
|
+
if (!previous) return localRefresh(runId);
|
|
811
|
+
const now = Date.now();
|
|
812
|
+
if (now - previous.loadedAtMs < ttlMs) return touch(runId, previous);
|
|
813
|
+
const stamps = currentStamps(previous);
|
|
814
|
+
if (sameStamps(stamps, previous.stamps)) return touch(runId, previous);
|
|
815
|
+
return localRefresh(runId);
|
|
816
|
+
}
|
|
817
|
+
const pendingRefreshes = new Map<string, ReturnType<typeof setTimeout>>();
|
|
818
|
+
const INVAL_COALESCE_MS = 80;
|
|
819
|
+
const scheduleRefresh = (runId: string): void => {
|
|
820
|
+
const existing = pendingRefreshes.get(runId);
|
|
821
|
+
if (existing) clearTimeout(existing);
|
|
822
|
+
pendingRefreshes.set(runId, setTimeout(() => {
|
|
823
|
+
pendingRefreshes.delete(runId);
|
|
824
|
+
try { localRefreshIfStale(runId); } catch { /* best-effort; widget falls back gracefully */ }
|
|
825
|
+
}, INVAL_COALESCE_MS));
|
|
826
|
+
};
|
|
827
|
+
const unsubState = runEventBus.onChannel("run:state", (event) => {
|
|
828
|
+
if (entries.has(event.runId)) scheduleRefresh(event.runId);
|
|
794
829
|
});
|
|
830
|
+
const unsubLifecycle = runEventBus.onChannel("worker:lifecycle", (event) => {
|
|
831
|
+
if (entries.has(event.runId)) scheduleRefresh(event.runId);
|
|
832
|
+
});
|
|
833
|
+
const unsubscribe = () => {
|
|
834
|
+
unsubState();
|
|
835
|
+
unsubLifecycle();
|
|
836
|
+
for (const timer of pendingRefreshes.values()) clearTimeout(timer);
|
|
837
|
+
pendingRefreshes.clear();
|
|
838
|
+
};
|
|
795
839
|
|
|
796
840
|
return {
|
|
797
841
|
get(runId: string): RunUiSnapshot | undefined {
|
|
@@ -799,20 +843,10 @@ export function createRunSnapshotCache(cwd: string, options: RunSnapshotCacheOpt
|
|
|
799
843
|
return entry ? touch(runId, entry) : undefined;
|
|
800
844
|
},
|
|
801
845
|
refresh(runId: string): RunUiSnapshot {
|
|
802
|
-
|
|
803
|
-
const entry = build(runId, previous);
|
|
804
|
-
entries.set(runId, entry);
|
|
805
|
-
evictIfNeeded();
|
|
806
|
-
return entry.snapshot;
|
|
846
|
+
return localRefresh(runId);
|
|
807
847
|
},
|
|
808
848
|
refreshIfStale(runId: string): RunUiSnapshot {
|
|
809
|
-
|
|
810
|
-
if (!previous) return this.refresh(runId);
|
|
811
|
-
const now = Date.now();
|
|
812
|
-
if (now - previous.loadedAtMs < ttlMs) return touch(runId, previous);
|
|
813
|
-
const stamps = currentStamps(previous);
|
|
814
|
-
if (sameStamps(stamps, previous.stamps)) return touch(runId, previous);
|
|
815
|
-
return this.refresh(runId);
|
|
849
|
+
return localRefreshIfStale(runId);
|
|
816
850
|
},
|
|
817
851
|
preloadStale,
|
|
818
852
|
preloadAllStale,
|
package/src/ui/widget/index.ts
CHANGED
|
@@ -18,13 +18,30 @@ import { spinnerBucket, spinnerFrame } from "../spinner.ts";
|
|
|
18
18
|
import { runEventBus } from "../run-event-bus.ts";
|
|
19
19
|
import { DEFAULT_UI } from "../../config/defaults.ts";
|
|
20
20
|
import { activeWidgetRuns, statusSummary } from "./widget-model.ts";
|
|
21
|
-
import { buildWidgetLines, colorWidgetLine, renderLines } from "./widget-renderer.ts";
|
|
21
|
+
import { buildWidgetLines, colorWidgetLine, renderLines, DEFAULT_WIDGET_WIDTH, TASK_DESC_MAX } from "./widget-renderer.ts";
|
|
22
22
|
import type { CrewWidgetModel, CrewWidgetState, WidgetRun } from "./widget-types.ts";
|
|
23
23
|
|
|
24
24
|
// Re-export types and helpers for backward compatibility
|
|
25
25
|
export type { WidgetRun, CrewWidgetModel, CrewWidgetState } from "./widget-types.ts";
|
|
26
26
|
export { activeWidgetRuns, statusSummary } from "./widget-model.ts";
|
|
27
|
-
export { buildWidgetLines as buildCrewWidgetLines, widgetHeader } from "./widget-renderer.ts";
|
|
27
|
+
export { buildWidgetLines as buildCrewWidgetLines, widgetHeader, DEFAULT_WIDGET_WIDTH, TASK_DESC_MAX } from "./widget-renderer.ts";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the real render width for widget lines, in priority order:
|
|
31
|
+
* 1. explicit `width` argument (e.g. from caller that already knows terminal width)
|
|
32
|
+
* 2. `process.stdout.columns` (works in Node when stdout is a TTY)
|
|
33
|
+
* 3. `DEFAULT_WIDGET_WIDTH` (100) — last-resort fallback so we never paint
|
|
34
|
+
* a line wider than the smallest expected TUI.
|
|
35
|
+
*
|
|
36
|
+
* Callers SHOULD pass the width they already hold (e.g. `WidgetRender.render(width)`
|
|
37
|
+
* in this file already receives one). This helper exists for paths that don't.
|
|
38
|
+
*/
|
|
39
|
+
export function getRenderWidth(width?: number): number {
|
|
40
|
+
if (Number.isFinite(width) && width! > 0) return Math.floor(width!);
|
|
41
|
+
const stdoutCols = (globalThis as { process?: { stdout?: { columns?: number } } }).process?.stdout?.columns;
|
|
42
|
+
if (Number.isFinite(stdoutCols) && stdoutCols! > 0) return Math.floor(stdoutCols!);
|
|
43
|
+
return DEFAULT_WIDGET_WIDTH;
|
|
44
|
+
}
|
|
28
45
|
export { notificationBadge } from "./widget-formatters.ts";
|
|
29
46
|
|
|
30
47
|
// ── Constants ─────────────────────────────────────────────────────────
|
|
@@ -57,7 +74,12 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
57
74
|
this.theme = asCrewTheme(themeLike);
|
|
58
75
|
this.cachedTheme = this.theme;
|
|
59
76
|
this.unsubscribeTheme = subscribeThemeChange(themeLike, () => this.invalidate());
|
|
60
|
-
this.unsubscribeEventBus =
|
|
77
|
+
this.unsubscribeEventBus = (() => {
|
|
78
|
+
const unsub1 = runEventBus.onChannel("run:state", () => this.invalidate());
|
|
79
|
+
const unsub2 = runEventBus.onChannel("worker:lifecycle", () => this.invalidate());
|
|
80
|
+
const unsub3 = runEventBus.onChannel("ui:invalidate", () => this.invalidate());
|
|
81
|
+
return () => { unsub1(); unsub2(); unsub3(); };
|
|
82
|
+
})();
|
|
61
83
|
}
|
|
62
84
|
|
|
63
85
|
private buildSignature(runs: WidgetRun[]): string {
|
|
@@ -103,7 +125,7 @@ class CrewWidgetComponent implements WidgetComponent {
|
|
|
103
125
|
const runningGlyph = spinnerFrame("widget-header");
|
|
104
126
|
|
|
105
127
|
if (this.cacheSignature !== signature || width !== this.cachedWidth || this.cachedTheme !== this.theme) {
|
|
106
|
-
this.cachedBaseLines = buildWidgetLines(this.model.cwd, 0, this.model.maxLines, runs, this.model.notificationCount ?? 0).map((line, index) => {
|
|
128
|
+
this.cachedBaseLines = buildWidgetLines(this.model.cwd, 0, this.model.maxLines, runs, this.model.notificationCount ?? 0, width).map((line, index) => {
|
|
107
129
|
if (index === 0 && line.length > 0) return `${runningGlyph}${line.slice(1)}`;
|
|
108
130
|
return line;
|
|
109
131
|
});
|
|
@@ -150,7 +172,7 @@ export function updateCrewWidget(
|
|
|
150
172
|
}
|
|
151
173
|
|
|
152
174
|
const runs = activeWidgetRuns(ctx.cwd, manifestCache, snapshotCache, preloadedManifests, workspaceId);
|
|
153
|
-
const lines = buildWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0);
|
|
175
|
+
const lines = buildWidgetLines(ctx.cwd, state.frame, maxLines, runs, state.notificationCount ?? 0, getRenderWidth());
|
|
154
176
|
const placement = config?.widgetPlacement ?? DEFAULT_UI.widgetPlacement;
|
|
155
177
|
|
|
156
178
|
ctx.ui.setStatus(STATUS_KEY, lines.length ? statusSummary(runs) : undefined);
|