pi-crew 0.11.0 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +155 -9
- package/README.md +161 -1037
- package/agents/verifier.md +18 -7
- package/dist/index.mjs +744 -90644
- package/docs/README.md +57 -46
- package/docs/architecture.md +87 -33
- package/docs/commands-reference.md +9 -5
- package/docs/troubleshooting.md +3 -2
- package/package.json +1 -3
- package/schema.json +39 -0
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
- package/skills/real-test-pi-crew/SKILL.md +371 -34
- package/src/agents/agent-config.ts +1 -1
- package/src/agents/discover-agents.ts +1 -1
- package/src/config/config-validation.ts +15 -2
- package/src/config/config.ts +47 -13
- package/src/config/defaults.ts +0 -1
- package/src/config/env-vars.ts +35 -0
- package/src/config/types.ts +19 -5
- package/src/errors.ts +2 -2
- package/src/extension/async-notifier.ts +23 -0
- package/src/extension/crew-vibes/config.ts +0 -21
- package/src/extension/crew-vibes/index.ts +0 -2
- package/src/extension/crew-vibes/render.ts +1 -50
- package/src/extension/help.ts +21 -12
- package/src/extension/knowledge-injection.ts +2 -1
- package/src/extension/management.ts +8 -3
- package/src/extension/notification-sink.ts +17 -0
- package/src/extension/register.ts +7 -2
- package/src/extension/registration/command-utils.ts +28 -2
- package/src/extension/registration/commands/dashboard.ts +11 -1
- package/src/extension/registration/commands/manage.ts +36 -19
- package/src/extension/registration/commands/run.ts +24 -2
- package/src/extension/registration/commands/shared.ts +23 -1
- package/src/extension/registration/commands/status.ts +25 -2
- package/src/extension/registration/context-builder.ts +8 -2
- package/src/extension/registration/health-notify-policy.ts +100 -0
- package/src/extension/registration/lazy-configurers.ts +35 -0
- package/src/extension/registration/lifecycle-handlers.ts +91 -30
- package/src/extension/registration/lifecycle.ts +75 -10
- package/src/extension/registration/observability.ts +98 -35
- package/src/extension/registration/registration-types.ts +7 -5
- package/src/extension/registration/runtime-cleanup.ts +9 -3
- package/src/extension/registration/subagent-helpers.ts +38 -0
- package/src/extension/registration/subagent-tools.ts +16 -6
- package/src/extension/registration/team-tool.ts +10 -3
- package/src/extension/registration/terminal-status-wiring.ts +172 -0
- package/src/extension/registration/viewers.ts +6 -0
- package/src/extension/registration/wire-cross-extension.ts +28 -0
- package/src/extension/run-compare.ts +220 -0
- package/src/extension/run-export.ts +37 -5
- package/src/extension/run-maintenance.ts +155 -5
- package/src/extension/team-tool/dispatch/index.ts +3 -2
- package/src/extension/team-tool/dispatch/manage.ts +5 -2
- package/src/extension/team-tool/goal.ts +4 -1
- package/src/extension/team-tool/handle-settings.ts +33 -4
- package/src/extension/team-tool/health-monitor.ts +21 -7
- package/src/extension/team-tool/lifecycle-actions.ts +49 -1
- package/src/extension/team-tool/plan.ts +10 -0
- package/src/extension/team-tool/routing-hint.ts +63 -0
- package/src/extension/team-tool/status.ts +4 -0
- package/src/extension/team-tool.ts +52 -6
- package/src/extension/webhook-notify.ts +382 -0
- package/src/observability/metric-sink.ts +12 -2
- package/src/prompt/prompt-runtime.ts +82 -31
- package/src/prompt/worker-events-channel.ts +12 -0
- package/src/runtime/README.md +1 -1
- package/src/runtime/async-runner.ts +87 -1
- package/src/runtime/background-runner.ts +313 -234
- package/src/runtime/broker/crew-broker.ts +17 -11
- package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
- package/src/runtime/broker/wait-status-cache.ts +1 -1
- package/src/runtime/child-pi/child-pi-timers.ts +1 -1
- package/src/runtime/child-pi/mock-fixtures.ts +48 -0
- package/src/runtime/crew-agent-records.ts +337 -45
- package/src/runtime/deadletter.ts +43 -1
- package/src/runtime/delegate-spawn.ts +5 -1
- package/src/runtime/dispatch-batch.ts +72 -5
- package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
- package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
- package/src/runtime/model/model-fallback.ts +21 -1
- package/src/runtime/model/pi-args.ts +8 -10
- package/src/runtime/recovery/crash-recovery.ts +25 -1
- package/src/runtime/run-worker.ts +12 -1
- package/src/runtime/scheduling/global-worker-cap.ts +13 -6
- package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
- package/src/runtime/scheduling/scheduler.ts +49 -13
- package/src/runtime/scheduling/semaphore.ts +148 -20
- package/src/runtime/scratchpad/README.md +1 -1
- package/src/runtime/scratchpad/protocol.ts +1 -1
- package/src/runtime/settings-store.ts +1 -1
- package/src/runtime/skill-instructions.ts +22 -0
- package/src/runtime/stale-reconciler.ts +85 -13
- package/src/runtime/task-display.ts +1 -1
- package/src/runtime/task-runner/pre-execution.ts +26 -2
- package/src/runtime/task-runner/prompt-builder.ts +142 -45
- package/src/runtime/task-runner.ts +21 -1
- package/src/runtime/team-runner.ts +38 -1
- package/src/runtime/workspace-lock.ts +4 -1
- package/src/schema/config-schema.ts +18 -0
- package/src/schema/team-tool-schema.ts +17 -0
- package/src/state/atomic-write.ts +53 -0
- package/src/state/contracts.ts +109 -0
- package/src/state/coordination/locks.ts +191 -33
- package/src/state/coordination/mailbox.ts +140 -15
- package/src/state/crew-init.ts +87 -12
- package/src/state/event-log/cursor.ts +37 -1
- package/src/state/event-log/event-log-rotation.ts +72 -7
- package/src/state/stores/active-run-registry.ts +13 -1
- package/src/state/stores/state-store.ts +112 -22
- package/src/state/types.ts +4 -0
- package/src/ui/adaptive-card.ts +65 -0
- package/src/ui/agents-jobs-browser.ts +70 -64
- package/src/ui/card-colors.ts +36 -7
- package/src/ui/dashboard-panes/agents-pane.ts +55 -14
- package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
- package/src/ui/dashboard-panes/health-pane.ts +7 -5
- package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
- package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
- package/src/ui/dashboard-panes/pane-theme.ts +21 -0
- package/src/ui/dashboard-panes/plan-pane.ts +63 -30
- package/src/ui/dashboard-panes/progress-pane.ts +3 -2
- package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
- package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
- package/src/ui/dwf-phase-display.ts +3 -20
- package/src/ui/format-helpers.ts +22 -0
- package/src/ui/heartbeat-aggregator.ts +34 -0
- package/src/ui/inline-panel/crew-editor.ts +13 -3
- package/src/ui/inline-panel/index.ts +60 -4
- package/src/ui/keybinding-map.ts +251 -35
- package/src/ui/live-conversation-overlay.ts +180 -47
- package/src/ui/live-run-sidebar.ts +134 -55
- package/src/ui/mascot.ts +32 -16
- package/src/ui/overlays/agent-picker-overlay.ts +81 -26
- package/src/ui/overlays/confirm-overlay.ts +55 -29
- package/src/ui/overlays/help-overlay.ts +108 -53
- package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
- package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
- package/src/ui/powerbar-publisher.ts +0 -1
- package/src/ui/rail.ts +333 -0
- package/src/ui/run-dashboard.ts +193 -79
- package/src/ui/run-snapshot-cache.ts +18 -1
- package/src/ui/settings-overlay.ts +81 -39
- package/src/ui/spinner.ts +26 -2
- package/src/ui/terminal-status.ts +7 -1
- package/src/ui/theme-adapter.ts +0 -45
- package/src/ui/theme-discovery.ts +12 -6
- package/src/ui/tool-progress-formatter.ts +128 -9
- package/src/ui/tool-renderers/brief-mode.ts +10 -67
- package/src/ui/tool-renderers/index.ts +374 -523
- package/src/ui/transcript-viewer.ts +30 -12
- package/src/ui/widget/index.ts +32 -52
- package/src/ui/widget/task-list.ts +64 -32
- package/src/ui/widget/widget-formatters.ts +3 -402
- package/src/ui/widget/widget-model.ts +28 -7
- package/src/ui/widget/widget-renderer.ts +201 -128
- package/src/ui/widget/widget-types.ts +0 -2
- package/src/utils/incremental-reader.ts +11 -3
- package/src/utils/paths.ts +94 -12
- package/src/utils/project-markers.ts +40 -0
- package/src/utils/visual.ts +0 -4
- package/src/worktree/worktree-manager.ts +206 -26
- package/workflows/distill.workflow.md +3 -3
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
- package/workflows/strict-fast-fix.workflow.md +1 -1
- package/docs/migration-v0.4-v0.5.md +0 -208
- package/docs/runtime-flow.md +0 -148
- package/src/extension/crew-vibes/figures.ts +0 -22
- package/src/extension/crew-vibes/font-detect.ts +0 -71
- package/src/ui/dynamic-border.ts +0 -35
- package/src/ui/loaders.ts +0 -6
- package/src/ui/overlay-stack.ts +0 -148
package/src/state/crew-init.ts
CHANGED
|
@@ -13,10 +13,20 @@
|
|
|
13
13
|
* The `node:path` import is retained as a *fallback* (only used when the
|
|
14
14
|
* binding is healthy). Don't add new dependencies on other pi-crew modules.
|
|
15
15
|
*
|
|
16
|
+
* TWO sanctioned exceptions (RR-020):
|
|
17
|
+
* - `node:os` — a builtin like fs/path, used ONLY inside try/catch so a
|
|
18
|
+
* jiti namespace race degrades to "no home/tmp boundary" (the pre-RR-020
|
|
19
|
+
* walk) instead of crashing ensureCrewDirectory.
|
|
20
|
+
* - `../utils/project-markers.ts` — a zero-import module of plain string
|
|
21
|
+
* literals, so it cannot itself suffer the namespace race and it keeps
|
|
22
|
+
* this resolver from drifting away from `src/utils/paths.ts`.
|
|
23
|
+
*
|
|
16
24
|
* See: https://github.com/baphuongna/pi-crew/issues/28
|
|
17
25
|
*/
|
|
18
26
|
import * as fs from "node:fs";
|
|
27
|
+
import * as os from "node:os";
|
|
19
28
|
import * as path from "node:path";
|
|
29
|
+
import { PROJECT_DIR_MARKERS, PROJECT_FILE_MARKERS } from "../utils/project-markers.ts";
|
|
20
30
|
import { atomicWriteFile } from "./atomic-write.ts";
|
|
21
31
|
import { updateGitignore } from "./gitignore-manager.ts";
|
|
22
32
|
|
|
@@ -164,29 +174,94 @@ function safeResolve(p: string, pathDep?: typeof path): string {
|
|
|
164
174
|
}
|
|
165
175
|
|
|
166
176
|
function findProjectRoot(start: string, pathDep?: typeof path): string | undefined {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
177
|
+
// Marker lists come from src/utils/project-markers.ts (RR-020 Fix 1) so this
|
|
178
|
+
// resolver can never drift from src/utils/paths.ts:computeRepoRoot again —
|
|
179
|
+
// the drift resolved `parent/subproject` (.pi) to `parent/.crew` here while
|
|
180
|
+
// paths.ts resolved the same cwd to `subproject/.pi/teams` (two roots).
|
|
181
|
+
// The module has NO imports of its own, so it is safe under the jiti
|
|
182
|
+
// namespace race documented at the top of this file.
|
|
183
|
+
// RR-020 hardening (cold-verify round 2): the marker arrays are the ONE
|
|
184
|
+
// critical-path dependency on a static import binding. Under the jiti
|
|
185
|
+
// namespace race (issue #28) a binding can arrive undefined — degrade to the
|
|
186
|
+
// narrow `.git`-only probe (findProjectRoot then returns undefined more
|
|
187
|
+
// often, so computeCrewRoot anchors the crew root at the cwd — the same
|
|
188
|
+
// fallback paths.ts uses) instead of throwing inside ensureCrewDirectory,
|
|
189
|
+
// the very function hardened for #28. Mirrors the defensive style of
|
|
190
|
+
// safeJoin/safeDirname/parseRoot and the lazy updateGitignore import.
|
|
191
|
+
function markerLists(): { dirs: string[]; files: string[] } {
|
|
192
|
+
try {
|
|
193
|
+
if (Array.isArray(PROJECT_DIR_MARKERS) && Array.isArray(PROJECT_FILE_MARKERS)) {
|
|
194
|
+
return { dirs: PROJECT_DIR_MARKERS as string[], files: PROJECT_FILE_MARKERS as string[] };
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
// namespace unavailable — fall through to the narrow probe
|
|
198
|
+
}
|
|
199
|
+
return { dirs: [".git"], files: [] };
|
|
200
|
+
}
|
|
201
|
+
const markers = markerLists();
|
|
202
|
+
const hasMarker = (dir: string): boolean =>
|
|
203
|
+
markers.dirs.some((marker) => fs.existsSync(safeJoin(dir, marker))) ||
|
|
204
|
+
markers.files.some((marker) => fs.existsSync(safeJoin(dir, marker)));
|
|
172
205
|
let current = safeResolve(start, pathDep);
|
|
206
|
+
// RR-020 cold-verify follow-up: match findRepoRoot (paths.ts) which
|
|
207
|
+
// realpaths the start BEFORE walking, so the boundary comparisons below
|
|
208
|
+
// compare canonical-to-canonical (macOS /var -> /private/var). Best-effort:
|
|
209
|
+
// ENOENT keeps the lexical path, exactly like findRepoRoot's fallback.
|
|
210
|
+
try {
|
|
211
|
+
current = fs.realpathSync(current);
|
|
212
|
+
} catch {
|
|
213
|
+
// keep the lexical resolution
|
|
214
|
+
}
|
|
215
|
+
// Use `parseRoot` (inlined above) to avoid `path.parse` for the critical
|
|
216
|
+
// termination root — fixes the jiti namespace race in issue #28. Computed
|
|
217
|
+
// from the RESOLVED `current` (post-realpath) so a root-prefix change
|
|
218
|
+
// through a symlink cannot desync `current !== root` (paths.ts computes
|
|
219
|
+
// path.parse on the realpath'd start for the same reason).
|
|
220
|
+
const root = parseRoot(current);
|
|
221
|
+
// RR-020 cold-verify follow-up (bug-029 parity): home/tmp boundary STOP.
|
|
222
|
+
// computeRepoRoot (paths.ts) refuses to check markers at $HOME or the temp
|
|
223
|
+
// root; this walk did not, so once the marker lists were unified onto the
|
|
224
|
+
// wide set, `$HOME/.pi` (created by userPiRoot()) became a marker HERE — a
|
|
225
|
+
// MARKERLESS cwd under $HOME resolved crew-init to $HOME (⇒
|
|
226
|
+
// $HOME/.pi/teams) while projectCrewRoot resolved to <cwd>/.crew: the
|
|
227
|
+
// two-roots bug Fix 1 was meant to kill, in a new shape. `os` access is
|
|
228
|
+
// defensive: under the jiti namespace race (issue #28) the binding can be
|
|
229
|
+
// undefined — degrade to "no boundary" (the pre-RR-020 walk), never crash.
|
|
230
|
+
let home: string | undefined;
|
|
231
|
+
let tempRoot: string | undefined;
|
|
232
|
+
try {
|
|
233
|
+
home = canonicalBoundary(os.homedir());
|
|
234
|
+
tempRoot = canonicalBoundary(os.tmpdir());
|
|
235
|
+
} catch {
|
|
236
|
+
home = undefined;
|
|
237
|
+
tempRoot = undefined;
|
|
238
|
+
}
|
|
239
|
+
const atBoundary = (dir: string): boolean => (home !== undefined && dir === home) || (tempRoot !== undefined && dir === tempRoot);
|
|
173
240
|
// Walk up to find project root
|
|
174
241
|
while (current !== root) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
for (const marker of fileMarkers) {
|
|
179
|
-
if (fs.existsSync(safeJoin(current, marker))) return current;
|
|
180
|
-
}
|
|
242
|
+
// Stop walking before checking markers at home or temp root.
|
|
243
|
+
if (atBoundary(current)) return undefined;
|
|
244
|
+
if (hasMarker(current)) return current;
|
|
181
245
|
const parent = safeDirname(current);
|
|
182
246
|
if (parent === current) break;
|
|
183
247
|
current = parent;
|
|
184
248
|
}
|
|
185
249
|
// Check root as fallback
|
|
186
|
-
if (
|
|
250
|
+
if (atBoundary(root)) return undefined;
|
|
251
|
+
if (hasMarker(root)) return root;
|
|
187
252
|
return undefined;
|
|
188
253
|
}
|
|
189
254
|
|
|
255
|
+
/** Canonicalize a boundary dir for comparison with the (realpath'd) walk.
|
|
256
|
+
* Best-effort: an unresolvable path stays lexical. */
|
|
257
|
+
function canonicalBoundary(p: string): string {
|
|
258
|
+
try {
|
|
259
|
+
return fs.realpathSync(p);
|
|
260
|
+
} catch {
|
|
261
|
+
return p;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
190
265
|
/**
|
|
191
266
|
* Compute the crew root directory for a given working directory.
|
|
192
267
|
* Matches src/utils/paths.ts:projectCrewRoot() logic.
|
|
@@ -114,6 +114,10 @@ export function readEvents(eventsPath: string): TeamEvent[] {
|
|
|
114
114
|
|
|
115
115
|
export interface EventCursorOptions {
|
|
116
116
|
sinceSeq?: number;
|
|
117
|
+
/** Delivery cap for one read. When `limit` truncates the result, `events` is
|
|
118
|
+
* a PREFIX of everything available — resume from the returned `nextSeq`
|
|
119
|
+
* (never from `nextByteOffset`; see the continuation contract on
|
|
120
|
+
* `readEventsCursor`). */
|
|
117
121
|
limit?: number;
|
|
118
122
|
fromByteOffset?: number;
|
|
119
123
|
/** R-03: generation the caller captured on its previous read. When set, a
|
|
@@ -528,6 +532,30 @@ export function clearEventsCursorTailCache(eventsPath?: string): void {
|
|
|
528
532
|
cursorTailCache.delete(eventsPath);
|
|
529
533
|
}
|
|
530
534
|
|
|
535
|
+
/**
|
|
536
|
+
* Read team events with an optional incremental anchor (`sinceSeq`) plus a
|
|
537
|
+
* delivery cap (`limit`).
|
|
538
|
+
*
|
|
539
|
+
* Continuation / backpressure contract (BR-08):
|
|
540
|
+
* - `nextSeq` is the continuation token for events that CARRY `metadata.seq`
|
|
541
|
+
* (scheduler-emitted events): the max seq among the events DELIVERED by
|
|
542
|
+
* this call — never a watermark past an undelivered event. Resume with
|
|
543
|
+
* `{ sinceSeq: nextSeq }` (`>` comparison) to receive the rest.
|
|
544
|
+
* LIMITS (cold-verify correction — documented, not fixed here): events
|
|
545
|
+
* WITHOUT `metadata.seq` (e.g. the worker progress channel) are filtered
|
|
546
|
+
* out of the sinceSeq path entirely, and DUPLICATE seqs cannot be paged
|
|
547
|
+
* through by seq. Those cases need the `fromByteOffset` anchor.
|
|
548
|
+
* - `total` is the number of events AVAILABLE for this read (after the
|
|
549
|
+
* sinceSeq filter, before the `limit` slice) — EXCEPT that the tail cap
|
|
550
|
+
* (TAIL_EVENT_CAP) may drop a prefix first, in which case `total` counts
|
|
551
|
+
* only the retained suffix.
|
|
552
|
+
* - `nextByteOffset` is an OPTIONAL incremental-read accelerator, and is only
|
|
553
|
+
* returned when it is a SAFE resume anchor: on the `fromByteOffset` path it
|
|
554
|
+
* is OMITTED whenever `limit` truncated the delta, because the underlying
|
|
555
|
+
* offset then points past the ENTIRE delta (undelivered tail included) —
|
|
556
|
+
* resuming from it would silently skip events. The default (non-byte)
|
|
557
|
+
* path never returns it at all.
|
|
558
|
+
*/
|
|
531
559
|
export function readEventsCursor(eventsPath: string, options: EventCursorOptions = {}): EventCursorResult {
|
|
532
560
|
// Incremental byte-offset path: read only new bytes since last known offset
|
|
533
561
|
if (options.fromByteOffset !== undefined) {
|
|
@@ -558,11 +586,19 @@ export function readEventsCursor(eventsPath: string, options: EventCursorOptions
|
|
|
558
586
|
const limit = positiveInteger(options.limit);
|
|
559
587
|
const events = limit !== undefined ? merged.slice(0, limit) : merged;
|
|
560
588
|
const returnedMaxSeq = events.reduce((max, event) => Math.max(max, event.metadata?.seq ?? 0), sinceSeq);
|
|
589
|
+
// BR-08: `newState.byteOffset` sits past the ENTIRE delta read above —
|
|
590
|
+
// including the events the `limit` slice just dropped. Handing that back
|
|
591
|
+
// as `nextByteOffset` let a caller resume PAST events it never saw (silent
|
|
592
|
+
// loss: the delta read then returns an empty tail). Only expose the
|
|
593
|
+
// accelerator when this call delivered the whole delta; under truncation
|
|
594
|
+
// the caller must continue from `nextSeq` (see the doc comment on
|
|
595
|
+
// readEventsCursor).
|
|
596
|
+
const deliveredWholeDelta = limit === undefined || merged.length <= limit;
|
|
561
597
|
return {
|
|
562
598
|
events,
|
|
563
599
|
nextSeq: returnedMaxSeq,
|
|
564
600
|
total: merged.length,
|
|
565
|
-
nextByteOffset: newState.byteOffset,
|
|
601
|
+
...(deliveredWholeDelta ? { nextByteOffset: newState.byteOffset } : {}),
|
|
566
602
|
generation: liveGen,
|
|
567
603
|
};
|
|
568
604
|
}
|
|
@@ -169,18 +169,83 @@ export interface CompactionResult {
|
|
|
169
169
|
* 6. Return compaction stats
|
|
170
170
|
*/
|
|
171
171
|
export function compactEventLog(eventsPath: string, config?: Partial<RotationConfig>): CompactionResult | undefined {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
172
|
+
// US-001 (2026-09-22): lock-scope reduction. The expensive part is the READ +
|
|
173
|
+
// parse of the whole log (prepareCompaction). It is read-only, so it does NOT
|
|
174
|
+
// need the append lock: hold the lock ONLY for the write+recover phase
|
|
175
|
+
// (applyCompactionUnlocked), which is what must be atomic w.r.t. appends.
|
|
176
|
+
//
|
|
177
|
+
// Why this is safe: appenders take the SAME lock, so an append can only land
|
|
178
|
+
// before our prepareCompaction read or after our applyCompactionUnlocked
|
|
179
|
+
// write — never inside the write. applyCompactionUnlocked already re-reads
|
|
180
|
+
// after the write and splices back any events that arrived in the window
|
|
181
|
+
// (C2 recovery), so no event is lost; it only becomes slightly more likely to
|
|
182
|
+
// splice, which is exactly the case it was built for.
|
|
183
|
+
//
|
|
184
|
+
// No concurrent-rotator guard is needed: this function is fully SYNCHRONOUS
|
|
185
|
+
// (prepareCompaction and withEventLogLockSync both block), so two calls on the
|
|
186
|
+
// same path cannot interleave within a process. An in-flight flag was tried
|
|
187
|
+
// and removed — it was untestable dead code (mutation survived), and the
|
|
188
|
+
// synchronous execution already serializes rotators. Cross-process rotators
|
|
189
|
+
// are serialized by the on-disk lock in withEventLogLockSync.
|
|
177
190
|
//
|
|
178
191
|
// NOTE (Round 24 BUG 1): callers ALREADY holding the event-log lock (e.g.
|
|
179
|
-
// appendEventInsideLock in event-log.ts) must call applyCompactionUnlocked
|
|
192
|
+
// appendEventInsideLock in event-log.ts) must still call applyCompactionUnlocked
|
|
180
193
|
// directly — calling compactEventLog from inside the lock deadlocks (the
|
|
181
194
|
// mkdir lock is not re-entrant → 5s timeout → compaction never ran → the
|
|
182
195
|
// log grew unbounded until events were silently dropped past 50MB).
|
|
183
|
-
|
|
196
|
+
const prepared = prepareCompaction(eventsPath, config);
|
|
197
|
+
if (!prepared) return undefined;
|
|
198
|
+
return compactPreparedEventLog(eventsPath, prepared);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* US-001 phase 3 — the LOCKED write phase, extracted so the read→write race is
|
|
203
|
+
* deterministically testable: a caller (test) can run prepareCompaction, append
|
|
204
|
+
* to the file (simulating another process landing events in the window), then
|
|
205
|
+
* call this and assert nothing was lost.
|
|
206
|
+
*
|
|
207
|
+
* prepareCompaction reads OUTSIDE the lock, so an appender may have added events
|
|
208
|
+
* between that read and this write. applyCompactionUnlocked writes exactly
|
|
209
|
+
* `prepared.lines` (the kept window from the snapshot), which would CLOBBER those
|
|
210
|
+
* newer events; its C2 recovery only re-splices events that were in `kept`, not
|
|
211
|
+
* ones it never saw. So under the lock we read the bytes appended after the
|
|
212
|
+
* snapshot offset and append them to the write. Appenders hold the same lock,
|
|
213
|
+
* so this read is a complete tail snapshot.
|
|
214
|
+
*/
|
|
215
|
+
export function compactPreparedEventLog(
|
|
216
|
+
eventsPath: string,
|
|
217
|
+
prepared: { lines: string; originalSize: number; originalCount: number; kept: TeamEvent[] },
|
|
218
|
+
): CompactionResult | undefined {
|
|
219
|
+
return withEventLogLockSync(eventsPath, () => {
|
|
220
|
+
const merged = { ...prepared, lines: prepared.lines + readTailSince(eventsPath, prepared.originalSize) };
|
|
221
|
+
return applyCompactionUnlocked(eventsPath, merged);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* US-001: return the raw text appended to `filePath` after `offset`, or "" when
|
|
227
|
+
* nothing was appended / the file was replaced by a smaller one (a foreign
|
|
228
|
+
* rotation — caller then proceeds without a splice, which is the pre-US-001
|
|
229
|
+
* behaviour). Must be called while holding the event-log lock.
|
|
230
|
+
*/
|
|
231
|
+
function readTailSince(filePath: string, offset: number): string {
|
|
232
|
+
try {
|
|
233
|
+
const size = fs.statSync(filePath).size;
|
|
234
|
+
if (size <= offset) return "";
|
|
235
|
+
const fd = fs.openSync(filePath, "r");
|
|
236
|
+
try {
|
|
237
|
+
const length = size - offset;
|
|
238
|
+
const buf = Buffer.alloc(length);
|
|
239
|
+
const read = fs.readSync(fd, buf, 0, length, offset);
|
|
240
|
+
const tail = buf.subarray(0, read).toString("utf-8");
|
|
241
|
+
// Only splice complete lines (a partial trailing write would corrupt JSONL).
|
|
242
|
+
return tail.endsWith("\n") ? tail : tail.slice(0, tail.lastIndexOf("\n") + 1);
|
|
243
|
+
} finally {
|
|
244
|
+
fs.closeSync(fd);
|
|
245
|
+
}
|
|
246
|
+
} catch {
|
|
247
|
+
return "";
|
|
248
|
+
}
|
|
184
249
|
}
|
|
185
250
|
|
|
186
251
|
/** Round 24 (BUG 1): the lock-free pre-read for compaction. Safe to run
|
|
@@ -61,6 +61,14 @@ function removeStaleRegistryLock(lockPath: string, staleMs: number): boolean {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function withRegistryLock<T>(fn: () => T): T {
|
|
64
|
+
// US-010 (2026-09-22): sleepSync below is INTENTIONAL — this lock is sync-only
|
|
65
|
+
// (fn is synchronous, and its callers at :352/:378 are sync). Converting the
|
|
66
|
+
// backoff to `await sleep` here would repeat the documented v0.9.26
|
|
67
|
+
// starvation: the sync spinner's sleepSync blocks the event loop while an
|
|
68
|
+
// in-process async holder's `await`-based release can never run. The lock is
|
|
69
|
+
// held only for a sub-millisecond read-modify-write and the loop is bounded by
|
|
70
|
+
// a 10s deadline, so blocking is bounded and rare (contention is cross-process
|
|
71
|
+
// only). See src/state/event-log/sequence-cache.ts:270 for the same reasoning.
|
|
64
72
|
const filePath = registryLockPath();
|
|
65
73
|
const staleMs = 30_000;
|
|
66
74
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -84,7 +92,11 @@ function withRegistryLock<T>(fn: () => T): T {
|
|
|
84
92
|
break;
|
|
85
93
|
} catch (error) {
|
|
86
94
|
const code = (error as NodeJS.ErrnoException).code;
|
|
87
|
-
|
|
95
|
+
// Windows lock contention surfaces as EPERM/EACCES/EBUSY (not EEXIST) while
|
|
96
|
+
// another handle holds the lock file open — cross-process contention in
|
|
97
|
+
// CI (run 36019884258). Treat as contention: bounded retry via the 10s
|
|
98
|
+
// deadline below, mirroring isLockContention() in coordination/locks.ts.
|
|
99
|
+
if (code !== "EEXIST" && code !== "EPERM" && code !== "EACCES" && code !== "EBUSY") throw error;
|
|
88
100
|
if (!removeStaleRegistryLock(filePath, staleMs) && Date.now() > deadline)
|
|
89
101
|
throw new Error("Active-run registry is locked by another operation.");
|
|
90
102
|
sleepSync(Math.min(250, 25 * 2 ** attempt));
|
|
@@ -193,19 +193,37 @@ function resolveRunStateRoot(cwd: string, runId: string): string | undefined {
|
|
|
193
193
|
const now = Date.now();
|
|
194
194
|
const cached = runStateRootCache.get(key);
|
|
195
195
|
if (cached && cached.expiresAt > now) return cached.root;
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
196
|
+
// F-L1 (2026-09-22): listing (run-index scopedRunRoots) UNIONS the user and
|
|
197
|
+
// project run roots, but this resolution used scopeBaseRoot's single XOR
|
|
198
|
+
// pick — a run living under the OTHER root (e.g. created by another cwd or
|
|
199
|
+
// before the repo got a marker) listed fine yet FAILED every by-ID lookup:
|
|
200
|
+
// team status / prune / forget / scheduler provenance all returned "not
|
|
201
|
+
// found". Try the primary root first (hot path unchanged — one contained
|
|
202
|
+
// resolve + cache hit), then fall back to the other root. The no-repo case
|
|
203
|
+
// never falls back to a project path, mirroring scopedRunRoots' `if
|
|
204
|
+
// (projectRoot)` guard — resolution sees exactly what listing sees.
|
|
205
|
+
const candidates = useProjectState(cwd) ? [projectCrewRoot(cwd), userCrewRoot()] : [userCrewRoot()];
|
|
206
|
+
for (const root of candidates) {
|
|
207
|
+
const runsRoot = path.join(root, DEFAULT_PATHS.state.runsSubdir);
|
|
208
|
+
const scopedPath = resolveContainedRelativePath(runsRoot, runId, "runId");
|
|
209
|
+
// resolveRealContainedPath deliberately ACCEPTS a missing target (write-path
|
|
210
|
+
// semantics: ENOENT is fine, only symlink/escape violations throw) — so
|
|
211
|
+
// existence must be probed explicitly. Without this, the primary root
|
|
212
|
+
// always "wins" with a phantom path and the fallback can never run.
|
|
213
|
+
if (!fs.existsSync(path.join(scopedPath, DEFAULT_PATHS.state.manifestFile))) continue;
|
|
214
|
+
try {
|
|
215
|
+
resolveRealContainedPath(runsRoot, runId);
|
|
216
|
+
} catch {
|
|
217
|
+
continue; // symlink/escape violation under this root — try the other
|
|
218
|
+
}
|
|
219
|
+
if (runStateRootCache.size >= RUN_STATE_ROOT_CACHE_MAX) {
|
|
220
|
+
const oldest = runStateRootCache.keys().next().value;
|
|
221
|
+
if (oldest !== undefined) runStateRootCache.delete(oldest);
|
|
222
|
+
}
|
|
223
|
+
runStateRootCache.set(key, { root: scopedPath, expiresAt: now + RUN_STATE_ROOT_TTL_MS });
|
|
224
|
+
return scopedPath;
|
|
206
225
|
}
|
|
207
|
-
|
|
208
|
-
return scopedPath;
|
|
226
|
+
return undefined;
|
|
209
227
|
}
|
|
210
228
|
|
|
211
229
|
// PERF (2026-08-24): the artifacts containment verdict (existsSync + lstat +
|
|
@@ -239,7 +257,13 @@ function validateRunManifestPaths(cwd: string, runId: string, manifest: TeamRunM
|
|
|
239
257
|
manifest.eventsPath !== path.join(stateRoot, "events.jsonl")
|
|
240
258
|
)
|
|
241
259
|
return false;
|
|
242
|
-
|
|
260
|
+
// F-L1 (2026-09-22): derive the artifacts parent from the RESOLVED stateRoot's
|
|
261
|
+
// base root (…/<base>/state/runs/<runId>), not scopeBaseRoot — cross-root
|
|
262
|
+
// runs (state under the fallback root, artifacts written beside it at
|
|
263
|
+
// creation) must validate against their OWN root; scopeBaseRoot would
|
|
264
|
+
// reject every fallback-resolved run and keep it invisible by ID.
|
|
265
|
+
const baseRoot = path.resolve(stateRoot, "..", "..", "..");
|
|
266
|
+
const artifactsParent = path.join(baseRoot, DEFAULT_PATHS.state.artifactsSubdir);
|
|
243
267
|
const expectedArtifactsRoot = resolveContainedRelativePath(artifactsParent, runId, "runId");
|
|
244
268
|
if (manifest.artifactsRoot !== expectedArtifactsRoot) return false;
|
|
245
269
|
// PERF (2026-08-24): memoized verdict — see artifactsVerdictCache above.
|
|
@@ -409,9 +433,14 @@ export function createRunManifest(params: {
|
|
|
409
433
|
runKind?: "team-run" | "goal-loop" | "dynamic-workflow";
|
|
410
434
|
/** round-14 P1-5: typed workflow arguments for .dwf.ts scripts (ctx.args<T>()). */
|
|
411
435
|
args?: unknown;
|
|
436
|
+
/** Deterministic-capture support (2026-09-22): pin the run ID and/or clock
|
|
437
|
+
* so tools like docs/ui-samples/capture.ts produce byte-stable output.
|
|
438
|
+
* Defaults preserve current behavior (generated id, wall clock). */
|
|
439
|
+
runId?: string;
|
|
440
|
+
now?: () => Date;
|
|
412
441
|
}): { manifest: TeamRunManifest; tasks: TeamTaskState[]; paths: RunPaths } {
|
|
413
|
-
const paths = createRunPaths(params.cwd);
|
|
414
|
-
const now = new Date().toISOString();
|
|
442
|
+
const paths = createRunPaths(params.cwd, params.runId);
|
|
443
|
+
const now = (params.now ? params.now() : new Date()).toISOString();
|
|
415
444
|
const tasks = params.workflow ? createTasksFromWorkflow(paths.runId, params.workflow, params.team, params.cwd, params.goal) : [];
|
|
416
445
|
const manifest: TeamRunManifest = {
|
|
417
446
|
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
@@ -479,7 +508,12 @@ export function createRunManifest(params: {
|
|
|
479
508
|
return { manifest, tasks, paths };
|
|
480
509
|
}
|
|
481
510
|
|
|
482
|
-
export function saveRunManifest(manifest: TeamRunManifest):
|
|
511
|
+
export function saveRunManifest(manifest: TeamRunManifest, options?: { allowTerminalExit?: boolean }): TeamRunManifest {
|
|
512
|
+
// Finding 8 (2026-09-23): terminal-preserve at the WRITE layer. Mid-flight
|
|
513
|
+
// savers (task-runner artifact/progress writes carrying a stale in-memory
|
|
514
|
+
// "running" manifest) previously overwrote an externally-written terminal
|
|
515
|
+
// status; merge/finalize had their own guards but every other save site did
|
|
516
|
+
// not. Guard here so NO raw save can erase a terminal disk status.
|
|
483
517
|
// FIX: Capture the cached tasks array + mtime/size BEFORE we invalidate the
|
|
484
518
|
// cache. The previous implementation re-read tasks.json from disk after the
|
|
485
519
|
// manifest write (a JSON.parse + fs.readFileSync per call), which defeated
|
|
@@ -502,12 +536,13 @@ export function saveRunManifest(manifest: TeamRunManifest): void {
|
|
|
502
536
|
// which is always safe.
|
|
503
537
|
invalidateRunCache(manifest.stateRoot);
|
|
504
538
|
const manifestPath = path.join(manifest.stateRoot, "manifest.json");
|
|
539
|
+
const effective = preserveDiskTerminalStatus(manifest, options?.allowTerminalExit);
|
|
505
540
|
// REVIEW FIX (2026-09-10): reverted WI-2.2's coalesced conversion —
|
|
506
541
|
// saveRunManifest is a SYNCHRONOUS persist by name/contract (tests assert
|
|
507
542
|
// it, broker loadRunManifestById is a cross-process reader, and the
|
|
508
543
|
// statSync-based cache repopulation below needs the real post-write
|
|
509
544
|
// mtime/size). The 50ms coalesce window broke all three.
|
|
510
|
-
atomicWriteJson(manifestPath,
|
|
545
|
+
atomicWriteJson(manifestPath, effective);
|
|
511
546
|
// FIX: Re-populate cache with actual mtime/size so loadRunManifestById
|
|
512
547
|
// doesn't miss the cache on next read. Without this, every load until
|
|
513
548
|
// TTL expires would hit disk because cached 0 !== any real mtime.
|
|
@@ -516,16 +551,17 @@ export function saveRunManifest(manifest: TeamRunManifest): void {
|
|
|
516
551
|
// fresh tasks should call saveRunTasks or loadRunTasks separately.
|
|
517
552
|
const manifestStat = fs.statSync(manifestPath);
|
|
518
553
|
setManifestCache(manifest.stateRoot, {
|
|
519
|
-
manifest,
|
|
554
|
+
manifest: effective,
|
|
520
555
|
tasks: cachedTasks,
|
|
521
556
|
manifestMtimeMs: manifestStat.mtimeMs,
|
|
522
557
|
manifestSize: manifestStat.size,
|
|
523
558
|
tasksMtimeMs: cachedTasksMtimeMs,
|
|
524
559
|
tasksSize: cachedTasksSize,
|
|
525
560
|
});
|
|
561
|
+
return effective;
|
|
526
562
|
}
|
|
527
563
|
|
|
528
|
-
export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise<
|
|
564
|
+
export async function saveRunManifestAsync(manifest: TeamRunManifest, options?: { allowTerminalExit?: boolean }): Promise<TeamRunManifest> {
|
|
529
565
|
// FIX: Capture cached tasks array + mtime/size BEFORE invalidating, same
|
|
530
566
|
// rationale as the sync saveRunManifest above. The async path previously
|
|
531
567
|
// always set tasks: [] with mtime/size 0, so any cache hit was guaranteed
|
|
@@ -538,7 +574,8 @@ export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise<v
|
|
|
538
574
|
// after a crash. See saveRunManifest for full explanation.
|
|
539
575
|
invalidateRunCache(manifest.stateRoot);
|
|
540
576
|
const manifestPath = path.join(manifest.stateRoot, "manifest.json");
|
|
541
|
-
|
|
577
|
+
const effective = preserveDiskTerminalStatus(manifest, options?.allowTerminalExit);
|
|
578
|
+
await atomicWriteJsonAsync(manifestPath, effective);
|
|
542
579
|
// FIX: Re-populate cache with actual mtime/size. See saveRunManifest.
|
|
543
580
|
// RACE GUARD: another concurrent async save (OPT-02) may unlink+rewrite
|
|
544
581
|
// manifest.json between our atomicWriteJsonAsync and this stat. If stat
|
|
@@ -553,13 +590,14 @@ export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise<v
|
|
|
553
590
|
manifestStat = { mtimeMs: 0, size: 0 };
|
|
554
591
|
}
|
|
555
592
|
setManifestCache(manifest.stateRoot, {
|
|
556
|
-
manifest,
|
|
593
|
+
manifest: effective,
|
|
557
594
|
tasks: cachedTasks,
|
|
558
595
|
manifestMtimeMs: manifestStat.mtimeMs,
|
|
559
596
|
manifestSize: manifestStat.size,
|
|
560
597
|
tasksMtimeMs: cachedTasksMtimeMs,
|
|
561
598
|
tasksSize: cachedTasksSize,
|
|
562
599
|
});
|
|
600
|
+
return effective;
|
|
563
601
|
}
|
|
564
602
|
|
|
565
603
|
/**
|
|
@@ -803,6 +841,44 @@ function saveManifestAndTasksAtomicSync(manifest: TeamRunManifest, tasks: TeamTa
|
|
|
803
841
|
export interface UpdateRunStatusOptions {
|
|
804
842
|
data?: Record<string, unknown>;
|
|
805
843
|
metadata?: Parameters<typeof appendEvent>[1]["metadata"];
|
|
844
|
+
/** Finding 8 (2026-09-23): allow leaving a TERMINAL disk status (resume is the
|
|
845
|
+
* only legitimate terminal-exit flow). Defaults to false — a raw save with an
|
|
846
|
+
* in-memory "running" manifest must never erase an externally-written
|
|
847
|
+
* cancelled/failed/completed status (live race team_20260923175042: cancel
|
|
848
|
+
* 17:51:08 → intermediate task-runner save re-wrote "running" → finalize
|
|
849
|
+
* completed — the cancel was fully erased). */
|
|
850
|
+
allowTerminalExit?: boolean;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Finding 8: statuses a disk write must never silently leave. Mirrors
|
|
854
|
+
* isRunTerminalPreserved (merge-loop.ts) — kept local to avoid a runtime dep
|
|
855
|
+
* from the store layer to the runtime layer. */
|
|
856
|
+
const DISK_TERMINAL_STATUSES: ReadonlySet<TeamRunManifest["status"]> = new Set(["cancelled", "failed", "completed"]);
|
|
857
|
+
|
|
858
|
+
/** Finding 8 write-layer guard: if the DISK manifest is terminal and the
|
|
859
|
+
* incoming write carries a NON-terminal status (the erase class — a mid-flight
|
|
860
|
+
* saver with a stale in-memory "running" manifest), preserve the disk
|
|
861
|
+
* status/summary/updatedAt while keeping every other incoming field (artifacts,
|
|
862
|
+
* usage, surface…). Terminal→terminal re-decisions (e.g. cancelling a run that
|
|
863
|
+
* just completed — pinned by resume-cancel.test.ts) are LEGITIMATE and pass
|
|
864
|
+
* through; they are governed by canTransitionRunStatus at the updateRunStatus
|
|
865
|
+
* layer. Returns the effective manifest to persist. */
|
|
866
|
+
function preserveDiskTerminalStatus(manifest: TeamRunManifest, allowTerminalExit: boolean | undefined): TeamRunManifest {
|
|
867
|
+
if (allowTerminalExit) return manifest;
|
|
868
|
+
// Terminal→terminal re-decisions pass through (governed by
|
|
869
|
+
// canTransitionRunStatus at the updateRunStatus layer); the guard applies
|
|
870
|
+
// ONLY to the erase class: a NON-terminal incoming status over terminal disk.
|
|
871
|
+
if (DISK_TERMINAL_STATUSES.has(manifest.status)) return manifest;
|
|
872
|
+
try {
|
|
873
|
+
const manifestPath = path.join(manifest.stateRoot, "manifest.json");
|
|
874
|
+
// Raw read (no cache): cross-process cancel writes must be seen NOW.
|
|
875
|
+
const raw = fs.readFileSync(manifestPath, "utf-8");
|
|
876
|
+
const disk = JSON.parse(raw) as TeamRunManifest;
|
|
877
|
+
if (!DISK_TERMINAL_STATUSES.has(disk.status) || disk.status === manifest.status) return manifest;
|
|
878
|
+
return { ...manifest, status: disk.status, summary: disk.summary, updatedAt: disk.updatedAt };
|
|
879
|
+
} catch {
|
|
880
|
+
return manifest; // no disk manifest yet (create) or unreadable — normal write
|
|
881
|
+
}
|
|
806
882
|
}
|
|
807
883
|
|
|
808
884
|
export function updateRunStatus(
|
|
@@ -820,7 +896,21 @@ export function updateRunStatus(
|
|
|
820
896
|
updatedAt: new Date().toISOString(),
|
|
821
897
|
summary: summary ?? manifest.summary,
|
|
822
898
|
};
|
|
823
|
-
|
|
899
|
+
// Finding 8 (2026-09-23): the write-layer guard may PRESERVE a terminal disk
|
|
900
|
+
// status (an external cancel/reconciler write the in-memory manifest never
|
|
901
|
+
// saw). In that case do NOT emit run.<status>, do NOT flip the manifest —
|
|
902
|
+
// record the refusal and return the preserved state. Resume passes
|
|
903
|
+
// allowTerminalExit for its legitimate cancelled→running transition.
|
|
904
|
+
const saved = saveRunManifest(updated, { allowTerminalExit: options.allowTerminalExit });
|
|
905
|
+
if (saved.status !== status) {
|
|
906
|
+
appendEvent(saved.eventsPath, {
|
|
907
|
+
type: "run.terminal_preserved",
|
|
908
|
+
runId: saved.runId,
|
|
909
|
+
message: `Preserved terminal status '${saved.status}'; refused in-memory transition to '${status}'.`,
|
|
910
|
+
data: { preserved: saved.status, refused: status },
|
|
911
|
+
});
|
|
912
|
+
return saved;
|
|
913
|
+
}
|
|
824
914
|
// Unregister from active-run-index when run reaches a terminal status.
|
|
825
915
|
// Without this, stale entries accumulate (e.g. integration tests in /tmp) and
|
|
826
916
|
// Pi UI shows ghost "queued" runs that are actually completed/failed/cancelled.
|
package/src/state/types.ts
CHANGED
|
@@ -543,6 +543,10 @@ export interface GoalLoopState {
|
|
|
543
543
|
nextTurnFeedback?: string;
|
|
544
544
|
/** The team-run of the current in-flight turn (for cancel/steer). */
|
|
545
545
|
currentRunId?: string;
|
|
546
|
+
/** GL-1b (2026-09-22): why the last turn (or the loop itself) failed —
|
|
547
|
+
* persisted at goal level because turn-run dirs are pruned (keep=10 at
|
|
548
|
+
* session start) and otherwise the reason becomes unrecoverable. */
|
|
549
|
+
lastTurnError?: string;
|
|
546
550
|
verdicts: GoalVerdict[];
|
|
547
551
|
history: {
|
|
548
552
|
runId: string;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdaptiveCard — a width-deferred card component (R2, 2026-09-16).
|
|
3
|
+
*
|
|
4
|
+
* Problem it fixes: pi-crew's tool-renderer cards used to be BUILT as a
|
|
5
|
+
* pre-formatted string at a guessed width (`ctx.width || process.stdout.columns
|
|
6
|
+
* || 116`) and handed to `new Text(...)`. Pi's ToolRenderContext carries NO
|
|
7
|
+
* width field — neither the TUI nor export-html pass one — so the guess could
|
|
8
|
+
* never match the real render column. `Text.render(width)` WRAPS lines wider
|
|
9
|
+
* than the column, which tore the box frame apart (top border split mid-line,
|
|
10
|
+
* right border vanished). This hit HTML export hardest: export renders every
|
|
11
|
+
* card at width=100 while a non-TTY export process builds at the 116 fallback.
|
|
12
|
+
*
|
|
13
|
+
* Design: build the frame at RENDER time, at the width the caller actually
|
|
14
|
+
* provides. The card receives a `builder(width) => string`; `render(width)`
|
|
15
|
+
* invokes it and delegates line-wrapping/padding to a zero-padding `Text`.
|
|
16
|
+
* Results are cached per width (mirroring Text's own cache) so repeated
|
|
17
|
+
* renders of an unchanged card cost nothing.
|
|
18
|
+
*
|
|
19
|
+
* Fail-visible: a builder exception surfaces as a red `✖ card render error`
|
|
20
|
+
* line instead of propagating into the TUI render loop (which has no per-
|
|
21
|
+
* component catch). Construction-time errors in the ToolRenderer wrappers
|
|
22
|
+
* keep their own catch — this is the third and final layer.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
26
|
+
import type { CrewComponent } from "./component.ts";
|
|
27
|
+
|
|
28
|
+
export class AdaptiveCard implements CrewComponent {
|
|
29
|
+
#builder: (width: number) => string;
|
|
30
|
+
#errorStyle: (text: string) => string;
|
|
31
|
+
#text = new Text("", 0, 0);
|
|
32
|
+
#cachedWidth: number | undefined;
|
|
33
|
+
#cachedLines: string[] | null = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param builder produces the full card text (frame included) for a width.
|
|
37
|
+
* Must keep every line within `width` visual columns — the same contract
|
|
38
|
+
* the old fixed-width builders already obeyed for their baked width.
|
|
39
|
+
* @param errorStyle theme.fg("error", …) adapter for the fail-visible line.
|
|
40
|
+
*/
|
|
41
|
+
constructor(builder: (width: number) => string, errorStyle: (text: string) => string = (t) => t) {
|
|
42
|
+
this.#builder = builder;
|
|
43
|
+
this.#errorStyle = errorStyle;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
invalidate(): void {
|
|
47
|
+
this.#cachedLines = null;
|
|
48
|
+
this.#cachedWidth = undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
render(width: number): string[] {
|
|
52
|
+
if (this.#cachedLines !== null && this.#cachedWidth === width) return this.#cachedLines;
|
|
53
|
+
let text: string;
|
|
54
|
+
try {
|
|
55
|
+
text = this.#builder(width);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
58
|
+
text = this.#errorStyle(`✖ card render error: ${msg.slice(0, 60)}`);
|
|
59
|
+
}
|
|
60
|
+
this.#text.setText(text);
|
|
61
|
+
this.#cachedLines = this.#text.render(width);
|
|
62
|
+
this.#cachedWidth = width;
|
|
63
|
+
return this.#cachedLines;
|
|
64
|
+
}
|
|
65
|
+
}
|