pi-crew 0.5.13 → 0.5.16
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 +139 -0
- package/README.md +1 -1
- package/docs/pi-crew-v0.5.14-audit-fix-plan.md +75 -0
- package/docs/pi-crew-v0.5.16-audit-fix-plan.md +35 -0
- package/docs/pi-crew-v0.5.17-audit-fix-plan.md +80 -0
- package/docs/skills/REFERENCE.md +11 -0
- package/package.json +1 -1
- package/skills/iterative-audit/SKILL.md +330 -0
- package/src/extension/management.ts +1 -1
- package/src/extension/plan-orchestrate.ts +0 -1
- package/src/extension/register.ts +16 -7
- package/src/extension/registration/viewers.ts +1 -1
- package/src/extension/run-index.ts +1 -1
- package/src/extension/team-tool/explain.ts +0 -1
- package/src/extension/team-tool/handle-schedule.ts +0 -1
- package/src/extension/team-tool/health-monitor.ts +0 -1
- package/src/extension/team-tool/run.ts +2 -2
- package/src/extension/team-tool/status.ts +1 -1
- package/src/extension/team-tool.ts +2 -30
- package/src/observability/exporters/otlp-exporter.ts +11 -1
- package/src/runtime/checkpoint.ts +19 -0
- package/src/runtime/child-pi.ts +1 -1
- package/src/runtime/crash-recovery.ts +1 -1
- package/src/runtime/crew-agent-records.ts +23 -3
- package/src/runtime/crew-hooks.ts +1 -1
- package/src/runtime/handoff-manager.ts +0 -1
- package/src/runtime/heartbeat-watcher.ts +1 -1
- package/src/runtime/live-session-runtime.ts +0 -1
- package/src/runtime/loop-gates.ts +0 -1
- package/src/runtime/mcp-proxy.ts +2 -2
- package/src/runtime/pipeline-runner.ts +1 -2
- package/src/runtime/task-runner/live-executor.ts +1 -2
- package/src/runtime/task-runner.ts +1 -1
- package/src/state/jsonl-writer.ts +24 -0
- package/src/state/locks.ts +66 -35
- package/src/state/run-metrics.ts +1 -2
- package/src/state/schedule.ts +13 -5
- package/src/state/state-store.ts +1 -1
- package/src/tools/safe-bash.ts +0 -1
- package/src/ui/crew-widget.ts +2 -2
- package/src/ui/render-diff.ts +1 -1
- package/src/ui/run-dashboard.ts +1 -2
- package/src/ui/tool-render.ts +20 -3
- package/src/utils/conflict-detect.ts +0 -1
- package/src/utils/gh-protocol.ts +0 -2
package/src/runtime/child-pi.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { getPiSpawnCommand } from "./pi-spawn.ts";
|
|
|
8
8
|
import { DEFAULT_CHILD_PI } from "../config/defaults.ts";
|
|
9
9
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
10
10
|
import { attachPostExitStdioGuard, trySignalChild } from "./post-exit-stdio-guard.ts";
|
|
11
|
-
import { redactJsonLine
|
|
11
|
+
import { redactJsonLine } from "../utils/redaction.ts";
|
|
12
12
|
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
13
13
|
import { registerChildProcess, unregisterChildProcess } from "../extension/crew-cleanup.ts";
|
|
14
14
|
|
|
@@ -11,7 +11,7 @@ import type { ManifestCache } from "./manifest-cache.ts";
|
|
|
11
11
|
import { checkProcessLiveness } from "./process-status.ts";
|
|
12
12
|
import { reconcileStaleRun, type ReconcileResult } from "./stale-reconciler.ts";
|
|
13
13
|
import { executeHook, appendHookEvent } from "../hooks/registry.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { unregisterActiveRun, readActiveRunRegistry } from "../state/active-run-registry.ts";
|
|
15
15
|
import { resolveRealContainedPath } from "../utils/safe-paths.ts";
|
|
16
16
|
import { projectCrewRoot, userCrewRoot } from "../utils/paths.ts";
|
|
17
17
|
import { terminateLiveAgentsForRun } from "./live-agent-manager.ts";
|
|
@@ -263,8 +263,28 @@ export function readCrewAgentStatus(manifest: TeamRunManifest, taskOrAgentId: st
|
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
const agentEventSeqCache = new Map<string, { size: number; mtimeMs: number; seq: number }>();
|
|
266
|
+
// FIX (Round 22, defensive cap): Bound the per-file-path cache. Without a cap,
|
|
267
|
+
// a long-running pi-crew process that spawns 1000s of agents accumulates 1000s
|
|
268
|
+
// of entries. Mirrors the `asyncAgentReaderCache` pattern (above) and the
|
|
269
|
+
// `NotificationRouter.SEEN_MAP_MAX_SIZE` pattern.
|
|
270
|
+
const AGENT_EVENT_SEQ_CACHE_MAX_ENTRIES = 1000;
|
|
266
271
|
const AGENT_EVENT_SEQ_SIDECAR = ".seq";
|
|
267
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Set an entry in the seq cache, evicting the oldest entries when the cache
|
|
275
|
+
* exceeds the cap. Map's natural insertion order means the first key is the
|
|
276
|
+
* oldest — same as the pattern used in `asyncAgentReaderCache`.
|
|
277
|
+
*/
|
|
278
|
+
function setAgentEventSeqCache(filePath: string, entry: { size: number; mtimeMs: number; seq: number }): void {
|
|
279
|
+
if (agentEventSeqCache.has(filePath)) agentEventSeqCache.delete(filePath);
|
|
280
|
+
agentEventSeqCache.set(filePath, entry);
|
|
281
|
+
while (agentEventSeqCache.size > AGENT_EVENT_SEQ_CACHE_MAX_ENTRIES) {
|
|
282
|
+
const oldest = agentEventSeqCache.keys().next().value;
|
|
283
|
+
if (oldest === undefined) break;
|
|
284
|
+
agentEventSeqCache.delete(oldest);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
268
288
|
function readSeqFromSidecar(filePath: string): number | undefined {
|
|
269
289
|
try {
|
|
270
290
|
const raw = fs.readFileSync(`${filePath}.${AGENT_EVENT_SEQ_SIDECAR}`, "utf-8");
|
|
@@ -295,7 +315,7 @@ function nextAgentEventSeq(filePath: string): number {
|
|
|
295
315
|
// FIX: Try sidecar file for O(1) lookup before falling back to O(n) scan.
|
|
296
316
|
const sidecarSeq = readSeqFromSidecar(filePath);
|
|
297
317
|
if (sidecarSeq !== undefined) {
|
|
298
|
-
|
|
318
|
+
setAgentEventSeqCache(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: sidecarSeq });
|
|
299
319
|
return sidecarSeq + 1;
|
|
300
320
|
}
|
|
301
321
|
let max = 0;
|
|
@@ -309,7 +329,7 @@ function nextAgentEventSeq(filePath: string): number {
|
|
|
309
329
|
max += 1;
|
|
310
330
|
}
|
|
311
331
|
}
|
|
312
|
-
|
|
332
|
+
setAgentEventSeqCache(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: max });
|
|
313
333
|
writeSeqToSidecar(filePath, max);
|
|
314
334
|
return max + 1;
|
|
315
335
|
}
|
|
@@ -321,7 +341,7 @@ export function appendCrewAgentEvent(manifest: TeamRunManifest, taskId: string,
|
|
|
321
341
|
fs.appendFileSync(filePath, `${JSON.stringify(redactSecrets({ seq, time: new Date().toISOString(), event }))}\n`, "utf-8");
|
|
322
342
|
try {
|
|
323
343
|
const stat = fs.statSync(filePath);
|
|
324
|
-
|
|
344
|
+
setAgentEventSeqCache(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
|
|
325
345
|
writeSeqToSidecar(filePath, seq);
|
|
326
346
|
} catch (error) {
|
|
327
347
|
logInternalError("crew-agent-records.stat", error, `filePath=${filePath}`);
|
|
@@ -146,7 +146,7 @@ export class HookRegistry {
|
|
|
146
146
|
emit(event: CrewHookEvent): void {
|
|
147
147
|
// Validate event type using type guard
|
|
148
148
|
if (!isValidEventType(event.type)) {
|
|
149
|
-
|
|
149
|
+
logInternalError("crew-hooks.unknown-event-type", new Error(`Unknown event type: ${event.type}`));
|
|
150
150
|
return;
|
|
151
151
|
}
|
|
152
152
|
|
|
@@ -55,7 +55,6 @@ export function isValidHandoffSummary(value: unknown): value is HandoffSummary {
|
|
|
55
55
|
*/
|
|
56
56
|
|
|
57
57
|
import type { TeamEvent } from "../state/event-log.ts";
|
|
58
|
-
import { appendEventAsync } from "../state/event-log.ts";
|
|
59
58
|
|
|
60
59
|
/**
|
|
61
60
|
* Represents a key decision made during task execution.
|
|
@@ -6,7 +6,7 @@ import { loadRunManifestById } from "../state/state-store.ts";
|
|
|
6
6
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
7
7
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
8
8
|
import type { ManifestCache } from "./manifest-cache.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { DEFAULT_GRADIENT_THRESHOLDS, heartbeatAgeMs, type GradientThresholds, type HeartbeatLevel } from "./heartbeat-gradient.ts";
|
|
10
10
|
|
|
11
11
|
export interface HeartbeatWatcherRouter {
|
|
12
12
|
enqueue(notification: NotificationDescriptor): boolean;
|
|
@@ -24,7 +24,6 @@ import { buildExtensionBridge } from "./live-extension-bridge.ts";
|
|
|
24
24
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
25
25
|
// prose-compressor imported for custom tool descriptions below;
|
|
26
26
|
// tool description compression for SDK-managed tools awaits SDK support.
|
|
27
|
-
import { compressToolDescription } from "./prose-compressor.ts";
|
|
28
27
|
import { buildSensitivePathConstraint } from "./sensitive-paths.ts";
|
|
29
28
|
import { collectLiveSessionHealth, formatLiveSessionDiagnostics, type LiveSessionHealth } from "./live-session-health.ts";
|
|
30
29
|
import { listLiveAgents } from "./live-agent-manager.ts";
|
package/src/runtime/mcp-proxy.ts
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
* when proxying from the parent.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
19
|
+
import { type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { type Static, type TSchema } from "@sinclair/typebox";
|
|
21
21
|
|
|
22
22
|
export interface McpProxyConfig {
|
|
23
23
|
/** Whether to enable MCP in the child session. */
|
|
@@ -2,8 +2,7 @@ import type { TeamTaskState } from "../state/types.ts";
|
|
|
2
2
|
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
3
3
|
import type { TeamConfig } from "../teams/team-config.ts";
|
|
4
4
|
import type { AgentConfig } from "../agents/agent-config.ts";
|
|
5
|
-
import {
|
|
6
|
-
import { appendEvent, appendEventAsync } from "../state/event-log.ts";
|
|
5
|
+
import { appendEventAsync } from "../state/event-log.ts";
|
|
7
6
|
import { mapConcurrent } from "./parallel-utils.ts";
|
|
8
7
|
|
|
9
8
|
/**
|
|
@@ -3,7 +3,6 @@ import type { AgentConfig } from "../../agents/agent-config.ts";
|
|
|
3
3
|
import type { CrewRuntimeConfig } from "../../config/config.ts";
|
|
4
4
|
import { writeArtifact } from "../../state/artifact-store.ts";
|
|
5
5
|
import {
|
|
6
|
-
appendEvent,
|
|
7
6
|
appendEventFireAndForget,
|
|
8
7
|
} from "../../state/event-log.ts";
|
|
9
8
|
import type {
|
|
@@ -11,7 +10,7 @@ import type {
|
|
|
11
10
|
TeamRunManifest,
|
|
12
11
|
TeamTaskState,
|
|
13
12
|
} from "../../state/types.ts";
|
|
14
|
-
import { loadRunManifestById
|
|
13
|
+
import { loadRunManifestById } from "../../state/state-store.ts";
|
|
15
14
|
import { persistSingleTaskUpdate } from "./state-helpers.ts";
|
|
16
15
|
import type { WorkflowStep } from "../../workflows/workflow-config.ts";
|
|
17
16
|
import { appendCrewAgentEvent, appendCrewAgentOutput, emptyCrewAgentProgress, recordFromTask, upsertCrewAgent } from "../crew-agent-records.ts";
|
|
@@ -11,7 +11,7 @@ import type {
|
|
|
11
11
|
} from "../state/types.ts";
|
|
12
12
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
13
13
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
14
|
-
import {
|
|
14
|
+
import { appendEventAsync, appendEventFireAndForget } from "../state/event-log.ts";
|
|
15
15
|
import { saveRunManifest } from "../state/state-store.ts";
|
|
16
16
|
import { createTaskClaim } from "../state/task-claims.ts";
|
|
17
17
|
import {
|
|
@@ -14,10 +14,17 @@ export interface JsonlWriteStream {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const DEFAULT_MAX_JSONL_BYTES = 50 * 1024 * 1024;
|
|
17
|
+
// FIX (Round 21, per-line cap): A single huge line could exhaust memory during
|
|
18
|
+
// redactJsonLine if an upstream caller constructs an enormous string. Cap each
|
|
19
|
+
// line at 1MB by default — large enough for any legitimate event payload, small
|
|
20
|
+
// enough to prevent memory blow-up. Mirrors the upstream oh-my-pi pattern of
|
|
21
|
+
// bounding chunk boundaries in Bun.file().writer().
|
|
22
|
+
const DEFAULT_MAX_LINE_BYTES = 1 * 1024 * 1024;
|
|
17
23
|
|
|
18
24
|
export interface JsonlWriterDeps {
|
|
19
25
|
createWriteStream?: (filePath: string) => JsonlWriteStream;
|
|
20
26
|
maxBytes?: number;
|
|
27
|
+
maxLineBytes?: number;
|
|
21
28
|
}
|
|
22
29
|
|
|
23
30
|
export interface JsonlWriter {
|
|
@@ -47,7 +54,9 @@ export function createJsonlWriter(filePath: string | undefined, source: Drainabl
|
|
|
47
54
|
let backpressured = false;
|
|
48
55
|
let closed = false;
|
|
49
56
|
let bytesWritten = 0;
|
|
57
|
+
let linesDroppedForSize = 0;
|
|
50
58
|
const maxBytes = deps.maxBytes ?? DEFAULT_MAX_JSONL_BYTES;
|
|
59
|
+
const maxLineBytes = deps.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES;
|
|
51
60
|
|
|
52
61
|
return {
|
|
53
62
|
writeLine(line: string) {
|
|
@@ -55,6 +64,21 @@ export function createJsonlWriter(filePath: string | undefined, source: Drainabl
|
|
|
55
64
|
const safeLine = redactJsonLine(line);
|
|
56
65
|
const chunk = `${safeLine}\n`;
|
|
57
66
|
const chunkBytes = Buffer.byteLength(chunk, "utf-8");
|
|
67
|
+
// FIX (Round 21, per-line cap): Drop oversize lines. Without this, a
|
|
68
|
+
// single huge payload (e.g. a 100MB base64-encoded transcript) would
|
|
69
|
+
// be buffered in memory by redactJsonLine AND queued in the write
|
|
70
|
+
// stream. We log the drop so silent loss is visible.
|
|
71
|
+
if (chunkBytes > maxLineBytes) {
|
|
72
|
+
linesDroppedForSize++;
|
|
73
|
+
if (linesDroppedForSize === 1 || linesDroppedForSize % 100 === 0) {
|
|
74
|
+
logInternalError(
|
|
75
|
+
"jsonl-writer.lineTooLarge",
|
|
76
|
+
new Error(`line size ${chunkBytes} exceeds maxLineBytes ${maxLineBytes}`),
|
|
77
|
+
`file=${filePath} dropped=${linesDroppedForSize}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
58
82
|
if (bytesWritten + chunkBytes > maxBytes) return;
|
|
59
83
|
try {
|
|
60
84
|
const ok = stream.write(chunk);
|
package/src/state/locks.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import type { TeamRunManifest } from "./types.ts";
|
|
4
5
|
import { DEFAULT_LOCKS } from "../config/defaults.ts";
|
|
5
6
|
import { sleepSync } from "../utils/sleep.ts";
|
|
@@ -59,22 +60,71 @@ function isLockHolderAlive(filePath: string): boolean {
|
|
|
59
60
|
}
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Lock file kinds. Discriminator written to the lock file payload so that:
|
|
65
|
+
* - Debugging tools (e.g. a future `pi-crew locks` command) can identify
|
|
66
|
+
* what a lock is protecting.
|
|
67
|
+
* - Cross-kind ambiguity is prevented if two locks somehow resolve to the
|
|
68
|
+
* same path (defense in depth).
|
|
69
|
+
* - Forward compat: new lock types can be added without changing the
|
|
70
|
+
* on-disk format (the `kind` field is the only discriminator).
|
|
71
|
+
*/
|
|
72
|
+
export type LockKind = "run" | "file";
|
|
73
|
+
|
|
74
|
+
function writeLockFile(filePath: string, token: string, kind: LockKind = "file"): void {
|
|
63
75
|
const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
|
|
64
76
|
try {
|
|
65
|
-
fs.writeSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }));
|
|
77
|
+
fs.writeSync(fd, JSON.stringify({ kind, pid: process.pid, createdAt: new Date().toISOString(), token }));
|
|
66
78
|
} finally {
|
|
67
79
|
fs.closeSync(fd);
|
|
68
80
|
}
|
|
69
81
|
}
|
|
70
82
|
|
|
71
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Read the token stored in a lock file. Returns undefined if the file
|
|
85
|
+
* cannot be read or parsed.
|
|
86
|
+
*/
|
|
87
|
+
function readLockToken(filePath: string): string | undefined {
|
|
88
|
+
try {
|
|
89
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
90
|
+
const parsed = JSON.parse(raw) as { token?: unknown };
|
|
91
|
+
return typeof parsed.token === "string" ? parsed.token : undefined;
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Release a lock file, but ONLY if the stored token matches. This prevents
|
|
99
|
+
* the "losing contender wipes winner's lock" race that occurs when:
|
|
100
|
+
* 1. Process A acquires lock with token T_A
|
|
101
|
+
* 2. Process B times out waiting, steals the lock (overwriting with T_B)
|
|
102
|
+
* 3. Process A finishes, tries to release — would otherwise rm Process B's lock
|
|
103
|
+
*
|
|
104
|
+
* With token matching, A's release is a no-op for B's lock.
|
|
105
|
+
*/
|
|
106
|
+
function releaseLock(filePath: string, token: string): void {
|
|
107
|
+
const stored = readLockToken(filePath);
|
|
108
|
+
if (stored === undefined || stored === token) {
|
|
109
|
+
try {
|
|
110
|
+
fs.rmSync(filePath, { force: true });
|
|
111
|
+
} catch {
|
|
112
|
+
// Best-effort cleanup. Either someone else with the same token got
|
|
113
|
+
// there first, or the lock is already gone — both are fine.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// If the stored token does not match, our lock has been stolen
|
|
117
|
+
// (probably stale and overtaken). Do not touch it — the new holder owns it.
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function acquireLockWithRetry(filePath: string, staleMs: number, kind: LockKind = "file"): string {
|
|
72
121
|
let attempt = 0;
|
|
73
122
|
const deadline = Date.now() + staleMs * 2;
|
|
74
123
|
while (true) {
|
|
124
|
+
const token = randomUUID();
|
|
75
125
|
try {
|
|
76
|
-
writeLockFile(filePath);
|
|
77
|
-
return;
|
|
126
|
+
writeLockFile(filePath, token, kind);
|
|
127
|
+
return token;
|
|
78
128
|
} catch (error) {
|
|
79
129
|
const code = (error as NodeJS.ErrnoException).code;
|
|
80
130
|
if (code !== "EEXIST") throw error;
|
|
@@ -105,21 +155,14 @@ function sleep(ms: number): Promise<void> {
|
|
|
105
155
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
106
156
|
}
|
|
107
157
|
|
|
108
|
-
function
|
|
109
|
-
try {
|
|
110
|
-
if (isLockStale(filePath, staleMs)) fs.rmSync(filePath, { force: true });
|
|
111
|
-
} catch {
|
|
112
|
-
// Ignore stale-check races.
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function acquireLockWithRetryAsync(filePath: string, staleMs: number): Promise<void> {
|
|
158
|
+
async function acquireLockWithRetryAsync(filePath: string, staleMs: number, kind: LockKind = "file"): Promise<string> {
|
|
117
159
|
let attempt = 0;
|
|
118
160
|
const deadline = Date.now() + staleMs * 2;
|
|
119
161
|
while (true) {
|
|
162
|
+
const token = randomUUID();
|
|
120
163
|
try {
|
|
121
|
-
writeLockFile(filePath);
|
|
122
|
-
return;
|
|
164
|
+
writeLockFile(filePath, token, kind);
|
|
165
|
+
return token;
|
|
123
166
|
} catch (error) {
|
|
124
167
|
const code = (error as NodeJS.ErrnoException).code;
|
|
125
168
|
if (code !== "EEXIST") throw error;
|
|
@@ -139,7 +182,6 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number): Pro
|
|
|
139
182
|
try {
|
|
140
183
|
fs.rmSync(filePath, { force: true });
|
|
141
184
|
} catch { /* race — let loop retry */ }
|
|
142
|
-
await readLockStateAsync(filePath, staleMs);
|
|
143
185
|
const delay = Math.min(250, 25 * 2 ** attempt);
|
|
144
186
|
await sleep(delay);
|
|
145
187
|
attempt++;
|
|
@@ -159,15 +201,12 @@ export function withFileLockSync<T>(filePath: string, fn: () => T, options: RunL
|
|
|
159
201
|
const lockFile = `${filePath}.lock`;
|
|
160
202
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
161
203
|
fs.mkdirSync(path.dirname(lockFile), { recursive: true });
|
|
162
|
-
acquireLockWithRetry(lockFile, staleMs);
|
|
204
|
+
const token = acquireLockWithRetry(lockFile, staleMs, "file");
|
|
163
205
|
try {
|
|
164
206
|
return fn();
|
|
165
207
|
} finally {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
} catch {
|
|
169
|
-
// Best-effort lock cleanup.
|
|
170
|
-
}
|
|
208
|
+
// Token-guarded release: don't rm the lock if it has been stolen.
|
|
209
|
+
releaseLock(lockFile, token);
|
|
171
210
|
}
|
|
172
211
|
}
|
|
173
212
|
|
|
@@ -175,15 +214,11 @@ export function withRunLockSync<T>(manifest: TeamRunManifest, fn: () => T, optio
|
|
|
175
214
|
const filePath = lockPath(manifest);
|
|
176
215
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
177
216
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
178
|
-
acquireLockWithRetry(filePath, staleMs);
|
|
217
|
+
const token = acquireLockWithRetry(filePath, staleMs, "run");
|
|
179
218
|
try {
|
|
180
219
|
return fn();
|
|
181
220
|
} finally {
|
|
182
|
-
|
|
183
|
-
fs.rmSync(filePath, { force: true });
|
|
184
|
-
} catch {
|
|
185
|
-
// Best-effort lock cleanup.
|
|
186
|
-
}
|
|
221
|
+
releaseLock(filePath, token);
|
|
187
222
|
}
|
|
188
223
|
}
|
|
189
224
|
|
|
@@ -191,14 +226,10 @@ export async function withRunLock<T>(manifest: TeamRunManifest, fn: () => Promis
|
|
|
191
226
|
const filePath = lockPath(manifest);
|
|
192
227
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
193
228
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
194
|
-
await acquireLockWithRetryAsync(filePath, staleMs);
|
|
229
|
+
const token = await acquireLockWithRetryAsync(filePath, staleMs, "run");
|
|
195
230
|
try {
|
|
196
231
|
return await fn();
|
|
197
232
|
} finally {
|
|
198
|
-
|
|
199
|
-
fs.rmSync(filePath, { force: true });
|
|
200
|
-
} catch {
|
|
201
|
-
// Best-effort lock cleanup.
|
|
202
|
-
}
|
|
233
|
+
releaseLock(filePath, token);
|
|
203
234
|
}
|
|
204
235
|
}
|
package/src/state/run-metrics.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { loadRunManifestById } from "./state-store.ts";
|
|
4
|
-
import { projectCrewRoot
|
|
4
|
+
import { projectCrewRoot } from "../utils/paths.ts";
|
|
5
5
|
import { atomicWriteJson, readJsonFile } from "./atomic-write.ts";
|
|
6
|
-
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
8
|
* Run metrics snapshot captured after a run completes (or on demand).
|
package/src/state/schedule.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* - parseRelativeTime(): "+10m" → ISO timestamp
|
|
8
8
|
* - parseInterval(): "5m" → milliseconds
|
|
9
9
|
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
10
13
|
|
|
11
14
|
import type { ScheduleStoreData, ScheduledTask } from "./types.ts";
|
|
12
15
|
|
|
@@ -88,8 +91,8 @@ export class ScheduleStore {
|
|
|
88
91
|
this.path = path;
|
|
89
92
|
this.data = { version: 1, jobs: [] };
|
|
90
93
|
try {
|
|
91
|
-
if (
|
|
92
|
-
const content =
|
|
94
|
+
if (fs.existsSync(path)) {
|
|
95
|
+
const content = fs.readFileSync(path, "utf-8");
|
|
93
96
|
const parsed = JSON.parse(content);
|
|
94
97
|
if (parsed && typeof parsed === "object" && "version" in parsed && "jobs" in parsed) {
|
|
95
98
|
this.data = parsed as ScheduleStoreData;
|
|
@@ -102,10 +105,15 @@ export class ScheduleStore {
|
|
|
102
105
|
|
|
103
106
|
private save(): void {
|
|
104
107
|
try {
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
fs.mkdirSync(path.dirname(this.path), { recursive: true });
|
|
109
|
+
fs.writeFileSync(this.path, JSON.stringify(this.data, null, 2), "utf-8");
|
|
107
110
|
} catch (error) {
|
|
108
|
-
|
|
111
|
+
// FIX (Round 21, L1): Use logInternalError for consistency with
|
|
112
|
+
// the rest of the codebase. Previously console.warn may not be
|
|
113
|
+
// visible in all environments (e.g. JSON-RPC mode, redirected
|
|
114
|
+
// stderr). Also import the dependency properly at the top of
|
|
115
|
+
// the file (this method used the legacy require() pattern).
|
|
116
|
+
logInternalError("schedule.save", error, `path=${this.path}`);
|
|
109
117
|
}
|
|
110
118
|
}
|
|
111
119
|
|
package/src/state/state-store.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { TeamRunManifest, TeamTaskState } from "./types.ts";
|
|
4
|
-
import { canTransitionRunStatus
|
|
4
|
+
import { canTransitionRunStatus } from "./contracts.ts";
|
|
5
5
|
import { unregisterActiveRun } from "./active-run-registry.ts";
|
|
6
6
|
import { atomicWriteJson, atomicWriteJsonAsync, atomicWriteJsonCoalesced, readJsonFile } from "./atomic-write.ts";
|
|
7
7
|
import { appendEvent } from "./event-log.ts";
|
package/src/tools/safe-bash.ts
CHANGED
package/src/ui/crew-widget.ts
CHANGED
|
@@ -9,8 +9,8 @@ import { getTaskUsage } from "../runtime/usage-tracker.ts";
|
|
|
9
9
|
import type { TeamRunManifest } from "../state/types.ts";
|
|
10
10
|
import type { ManifestCache } from "../runtime/manifest-cache.ts";
|
|
11
11
|
import { reconcileAllStaleRuns } from "../runtime/crash-recovery.ts";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { iconForStatus } from "./status-colors.ts";
|
|
13
|
+
import { truncate } from "../utils/visual.ts";
|
|
14
14
|
import type { CrewTheme } from "./theme-adapter.ts";
|
|
15
15
|
import { asCrewTheme, subscribeThemeChange } from "./theme-adapter.ts";
|
|
16
16
|
import { Box, Text } from "./layout-primitives.ts";
|
package/src/ui/render-diff.ts
CHANGED
|
@@ -18,7 +18,7 @@ function parseDiffLine(line: string): ParsedDiffLine | null {
|
|
|
18
18
|
return { prefix: match[1], lineNum: match[2], content: match[3] };
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function replaceTabs(text: string): string {
|
|
21
|
+
export function replaceTabs(text: string): string {
|
|
22
22
|
return text.replace(/\t/g, " ");
|
|
23
23
|
}
|
|
24
24
|
|
package/src/ui/run-dashboard.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import type { TeamRunManifest, TeamTaskState, UsageState } from "../state/types.ts";
|
|
3
3
|
import { readCrewAgents } from "../runtime/crew-agent-records.ts";
|
|
4
|
-
import { getLiveAgentContextPercent
|
|
4
|
+
import { getLiveAgentContextPercent } from "../runtime/live-agent-manager.ts";
|
|
5
5
|
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
6
6
|
import { isDisplayActiveRun, isLikelyOrphanedActiveRun } from "../runtime/process-status.ts";
|
|
7
7
|
import { readJsonFileCoalesced } from "../utils/file-coalescer.ts";
|
|
@@ -11,7 +11,6 @@ import { applyStatusColor, iconForStatus, type RunStatus } from "./status-colors
|
|
|
11
11
|
import { pad, truncate, sanitizeLine } from "../utils/visual.ts";
|
|
12
12
|
import { Box, Text } from "./layout-primitives.ts";
|
|
13
13
|
import { DynamicCrewBorder } from "./dynamic-border.ts";
|
|
14
|
-
import { CrewFooter } from "./crew-footer.ts";
|
|
15
14
|
import { aggregateUsage } from "../state/usage.ts";
|
|
16
15
|
import { logInternalError } from "../utils/internal-error.ts";
|
|
17
16
|
import { renderAgentsPane } from "./dashboard-panes/agents-pane.ts";
|
package/src/ui/tool-render.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { Container, Spacer, Text, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
7
|
import type { CrewAgentRecord } from "../runtime/crew-agent-runtime.ts";
|
|
8
|
+
import { replaceTabs } from "./render-diff.ts";
|
|
8
9
|
|
|
9
10
|
// ── Types ──────────────────────────────────────────────────────────────
|
|
10
11
|
export interface Theme {
|
|
@@ -185,7 +186,15 @@ export function renderAgentProgress(
|
|
|
185
186
|
}
|
|
186
187
|
|
|
187
188
|
// Error
|
|
188
|
-
|
|
189
|
+
// FIX (Round 20, render-utils sanitization): Sanitize tool-error display so
|
|
190
|
+
// embedded tabs / control chars / newlines / very long strings cannot break
|
|
191
|
+
// the terminal layout. Mirrors the upstream oh-my-pi pattern at
|
|
192
|
+
// packages/coding-agent/src/tools/render-utils.ts:177-185:
|
|
193
|
+
// formatErrorMessage = replaceTabs(truncateToWidth(clean, LINE_CAP))
|
|
194
|
+
if (record.error) {
|
|
195
|
+
const clean = truncLine(replaceTabs(String(record.error)), innerW);
|
|
196
|
+
addLine(theme.fg("error", `Error: ${clean}`));
|
|
197
|
+
}
|
|
189
198
|
|
|
190
199
|
// Usage line
|
|
191
200
|
const usage = record.usage;
|
|
@@ -300,7 +309,12 @@ export function renderAgentToolResult(
|
|
|
300
309
|
const label = item.agentId || "agent";
|
|
301
310
|
c.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(label))}`, 0, 0));
|
|
302
311
|
if (item.error) {
|
|
303
|
-
|
|
312
|
+
// FIX (Round 20, render-utils sanitization): Sanitize tool-error
|
|
313
|
+
// display so embedded tabs / newlines / very long strings cannot
|
|
314
|
+
// break the TUI border alignment. Mirrors upstream oh-my-pi
|
|
315
|
+
// render-utils.ts:177-185.
|
|
316
|
+
const clean = truncLine(replaceTabs(String(item.error)), w - 2);
|
|
317
|
+
c.addChild(new Text(theme.fg("error", ` Error: ${clean}`), 0, 0));
|
|
304
318
|
} else if (item.output) {
|
|
305
319
|
for (const line of item.output.split("\n").slice(0, 5))
|
|
306
320
|
c.addChild(new Text(theme.fg("dim", ` ${truncLine(line, w - 2)}`), 0, 0));
|
|
@@ -318,7 +332,10 @@ export function renderAgentToolResult(
|
|
|
318
332
|
const label = d.agentId;
|
|
319
333
|
c.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold(label))}`, 0, 0));
|
|
320
334
|
if (d.error) {
|
|
321
|
-
|
|
335
|
+
// FIX (Round 20, render-utils sanitization): Same sanitization as
|
|
336
|
+
// above — see renderAgentToolResult header comment.
|
|
337
|
+
const clean = truncLine(replaceTabs(String(d.error)), w - 2);
|
|
338
|
+
c.addChild(new Text(theme.fg("error", ` Error: ${clean}`), 0, 0));
|
|
322
339
|
} else if (d.output) {
|
|
323
340
|
for (const line of d.output.split("\n").slice(0, 5))
|
|
324
341
|
c.addChild(new Text(theme.fg("dim", ` ${truncLine(line, w - 2)}`), 0, 0));
|
package/src/utils/gh-protocol.ts
CHANGED
|
@@ -22,8 +22,6 @@
|
|
|
22
22
|
* Repo resolution: git remote get-url origin from cwd.
|
|
23
23
|
*/
|
|
24
24
|
import { execFileSync } from "node:child_process";
|
|
25
|
-
import { readFileSync } from "node:fs";
|
|
26
|
-
import * as path from "node:path";
|
|
27
25
|
|
|
28
26
|
/** Resolve the default repo from `git remote get-url origin` in cwd. */
|
|
29
27
|
export function resolveDefaultRepo(cwd: string): string {
|