pi-crew 0.9.34 → 0.9.35
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 +109 -0
- package/dist/build-meta.json +257 -261
- package/dist/index.mjs +2127 -1937
- package/dist/index.mjs.map +4 -4
- package/docs/performance-optimization-execution-plan.md +520 -0
- package/package.json +1 -1
- package/src/extension/team-tool/api.ts +8 -8
- package/src/extension/team-tool/goal-wrap.ts +2 -2
- package/src/extension/team-tool/goal.ts +2 -2
- package/src/extension/team-tool/handle-schedule.ts +102 -0
- package/src/extension/team-tool/run.ts +0 -42
- package/src/extension/team-tool.ts +13 -8
- package/src/runtime/adaptive-plan.ts +3 -3
- package/src/runtime/background-runner.ts +4 -4
- package/src/runtime/child-pi.ts +191 -56
- package/src/runtime/live-session-runtime.ts +27 -12
- package/src/runtime/skill-instructions.ts +61 -2
- package/src/runtime/stale-reconciler.ts +5 -0
- package/src/runtime/task-runner/tail-read.ts +12 -0
- package/src/runtime/task-runner.ts +40 -19
- package/src/runtime/team-runner.ts +95 -52
- package/src/schema/team-tool-schema.ts +4 -0
- package/src/state/atomic-write.ts +5 -1
- package/src/state/state-store.ts +75 -2
- package/src/utils/token-counter.ts +168 -35
- package/workflows/pipeline.workflow.md +13 -12
|
@@ -151,8 +151,20 @@ type LiveSessionLike = {
|
|
|
151
151
|
|
|
152
152
|
function appendTranscript(filePath: string | undefined, event: unknown): void {
|
|
153
153
|
if (!filePath) return;
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
// Fire-and-forget async write to avoid blocking the event loop.
|
|
155
|
+
// Transcript writes are best-effort telemetry — callers do not need to await.
|
|
156
|
+
void appendTranscriptAsync(filePath, event);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Async version of appendTranscript — fire-and-forget for non-blocking writes. */
|
|
160
|
+
async function appendTranscriptAsync(filePath: string, event: unknown): Promise<void> {
|
|
161
|
+
try {
|
|
162
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
163
|
+
const content = `${JSON.stringify(redactSecrets(event))}\n`;
|
|
164
|
+
await fs.promises.appendFile(filePath, content, "utf-8");
|
|
165
|
+
} catch (error) {
|
|
166
|
+
logInternalError("live-session.transcript-write-failed", error as Error, `path=${filePath}`);
|
|
167
|
+
}
|
|
156
168
|
}
|
|
157
169
|
|
|
158
170
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
@@ -588,13 +600,20 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
588
600
|
jsonEvents: 0,
|
|
589
601
|
error: "createAgentSession export is unavailable.",
|
|
590
602
|
};
|
|
603
|
+
// OPT-03: compute yield-enabled flag early to conditionally allocate
|
|
604
|
+
// collectedJsonEvents — avoids ~1KB array allocation per task when yield
|
|
605
|
+
// detection is disabled. Pattern mirrors task-runner.ts:165. Uses the same
|
|
606
|
+
// `enabled !== false` semantics as the late yield-detection block below so
|
|
607
|
+
// the array is always allocated when (and only when) it will be read.
|
|
608
|
+
const yieldConfig = input.runtimeConfig?.yield ?? { enabled: DEFAULT_YIELD_CONFIG.enabled };
|
|
609
|
+
const yieldEnabled = yieldConfig.enabled !== false;
|
|
591
610
|
let session: LiveSessionLike | undefined;
|
|
592
611
|
let unsubscribe: (() => void) | undefined;
|
|
593
612
|
let unsubscribeControlRealtime: (() => void) | undefined;
|
|
594
613
|
let controlTimer: ReturnType<typeof setInterval> | undefined;
|
|
595
614
|
let stdout = "";
|
|
596
615
|
let jsonEvents = 0;
|
|
597
|
-
const collectedJsonEvents: Record<string, unknown>[] = [];
|
|
616
|
+
const collectedJsonEvents: Record<string, unknown>[] | undefined = yieldEnabled ? [] : undefined;
|
|
598
617
|
const maxCollectedJsonEvents = 1000;
|
|
599
618
|
let yieldResult: YieldResult | undefined;
|
|
600
619
|
|
|
@@ -854,7 +873,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
854
873
|
trackLiveAgentToolEnd(agentId, toolName);
|
|
855
874
|
}
|
|
856
875
|
// Phase 1: collect events for yield detection
|
|
857
|
-
if (event && typeof event === "object" && !Array.isArray(event)) {
|
|
876
|
+
if (collectedJsonEvents && event && typeof event === "object" && !Array.isArray(event)) {
|
|
858
877
|
collectedJsonEvents.push(event as Record<string, unknown>);
|
|
859
878
|
if (collectedJsonEvents.length > maxCollectedJsonEvents)
|
|
860
879
|
collectedJsonEvents.splice(0, collectedJsonEvents.length - maxCollectedJsonEvents);
|
|
@@ -936,16 +955,12 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
936
955
|
// --- Phase 1: Yield enforcement loop ---
|
|
937
956
|
// After the initial prompt completes, check if the worker called submit_result.
|
|
938
957
|
// Priority: 1) custom tool callback (G1), 2) JSON event detection (legacy).
|
|
939
|
-
const yieldConfig = input.runtimeConfig?.yield ?? {
|
|
940
|
-
enabled: DEFAULT_YIELD_CONFIG.enabled,
|
|
941
|
-
};
|
|
942
|
-
const yieldEnabled = yieldConfig.enabled !== false;
|
|
943
958
|
if (yieldEnabled && session) {
|
|
944
959
|
// Check custom tool callback first (G1)
|
|
945
960
|
if (customToolYieldResolved && customToolYieldResult) {
|
|
946
961
|
yieldResult = customToolYieldResult;
|
|
947
|
-
} else {
|
|
948
|
-
// Legacy: detect from JSON events
|
|
962
|
+
} else if (collectedJsonEvents) {
|
|
963
|
+
// Legacy: detect from JSON events (only when collection is enabled)
|
|
949
964
|
const alreadyYielded = hasYieldInOutput(collectedJsonEvents);
|
|
950
965
|
if (alreadyYielded) {
|
|
951
966
|
const yieldEvent = collectedJsonEvents.find((e) => isYieldEvent(e));
|
|
@@ -976,7 +991,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
976
991
|
// Check again after schema reminder
|
|
977
992
|
if (customToolYieldResolved && customToolYieldResult) {
|
|
978
993
|
yieldResult = customToolYieldResult;
|
|
979
|
-
} else {
|
|
994
|
+
} else if (collectedJsonEvents) {
|
|
980
995
|
const newEvents = collectedJsonEvents.slice(-10);
|
|
981
996
|
if (hasYieldInOutput(newEvents)) {
|
|
982
997
|
const yieldEvent = newEvents.find((e) => isYieldEvent(e));
|
|
@@ -1027,7 +1042,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
|
|
|
1027
1042
|
break;
|
|
1028
1043
|
}
|
|
1029
1044
|
// Legacy: check JSON events
|
|
1030
|
-
if (hasYieldInOutput(collectedJsonEvents.slice(-10))) {
|
|
1045
|
+
if (collectedJsonEvents && hasYieldInOutput(collectedJsonEvents.slice(-10))) {
|
|
1031
1046
|
const yieldEvent = collectedJsonEvents.slice(-10).find((e) => isYieldEvent(e));
|
|
1032
1047
|
if (yieldEvent) yieldResult = extractYieldResult(yieldEvent);
|
|
1033
1048
|
break;
|
|
@@ -20,8 +20,35 @@ const MAX_SKILL_CHARS = 1500;
|
|
|
20
20
|
const MAX_TOTAL_CHARS = 6000;
|
|
21
21
|
const MAX_SKILL_NAME_CHARS = 80;
|
|
22
22
|
const MAX_SELECTED_SKILLS = 32;
|
|
23
|
+
|
|
24
|
+
// ── Cache sizing rationale (OPT-05 analysis, 2026-07-13) ───────────────────
|
|
25
|
+
// The package ships 30 SKILL.md files; 11 are referenced by DEFAULT_ROLE_SKILLS.
|
|
26
|
+
// Cache key = `${cwd}:${skillName}`, so a single-cwd run needs ≤30 entries.
|
|
27
|
+
// Multi-cwd scenarios (worktree isolation) multiply this, but rarely exceed 90.
|
|
28
|
+
// 128 provides 4× headroom over the skill count, ~1.4× over a 3-cwd scenario.
|
|
29
|
+
// Memory per entry ≈ 5KB (raw + compacted body), so 128 entries ≈ 640KB — negligible.
|
|
30
|
+
// Conclusion: 128 is well-tuned. Evictions are near-zero in practice.
|
|
23
31
|
const SKILL_CACHE_MAX_ENTRIES = 128;
|
|
24
32
|
|
|
33
|
+
export interface SkillCacheStats {
|
|
34
|
+
hits: number;
|
|
35
|
+
misses: number;
|
|
36
|
+
evictions: number;
|
|
37
|
+
currentSize: number;
|
|
38
|
+
maxEntries: number;
|
|
39
|
+
/** Computed hit-rate: hits / (hits + misses), 0 when no lookups yet. */
|
|
40
|
+
hitRate: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const skillCacheStats: SkillCacheStats = {
|
|
44
|
+
hits: 0,
|
|
45
|
+
misses: 0,
|
|
46
|
+
evictions: 0,
|
|
47
|
+
currentSize: 0,
|
|
48
|
+
maxEntries: SKILL_CACHE_MAX_ENTRIES,
|
|
49
|
+
hitRate: 0,
|
|
50
|
+
};
|
|
51
|
+
|
|
25
52
|
const DEFAULT_ROLE_SKILLS: Record<string, string[]> = {
|
|
26
53
|
explorer: ["read-only-explorer", "context-artifact-hygiene"],
|
|
27
54
|
analyst: ["read-only-explorer", "requirements-to-task-packet"],
|
|
@@ -146,16 +173,43 @@ const skillReadCache = new Map<string, CachedSkillMarkdown>();
|
|
|
146
173
|
function rememberSkill(key: string, value: CachedSkillMarkdown): CachedSkillMarkdown {
|
|
147
174
|
if (skillReadCache.has(key)) skillReadCache.delete(key);
|
|
148
175
|
skillReadCache.set(key, value);
|
|
149
|
-
while (skillReadCache.size >
|
|
176
|
+
while (skillReadCache.size > skillCacheStats.maxEntries) {
|
|
150
177
|
const oldest = skillReadCache.keys().next().value;
|
|
151
178
|
if (!oldest) break;
|
|
152
179
|
skillReadCache.delete(oldest);
|
|
180
|
+
skillCacheStats.evictions++;
|
|
153
181
|
}
|
|
182
|
+
skillCacheStats.currentSize = skillReadCache.size;
|
|
154
183
|
return value;
|
|
155
184
|
}
|
|
156
185
|
|
|
157
186
|
export function clearSkillInstructionCache(): void {
|
|
158
187
|
skillReadCache.clear();
|
|
188
|
+
skillCacheStats.currentSize = 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function getSkillCacheStats(): SkillCacheStats {
|
|
192
|
+
const total = skillCacheStats.hits + skillCacheStats.misses;
|
|
193
|
+
return {
|
|
194
|
+
...skillCacheStats,
|
|
195
|
+
currentSize: skillReadCache.size,
|
|
196
|
+
hitRate: total > 0 ? skillCacheStats.hits / total : 0,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function resetSkillCacheStats(): void {
|
|
201
|
+
skillCacheStats.hits = 0;
|
|
202
|
+
skillCacheStats.misses = 0;
|
|
203
|
+
skillCacheStats.evictions = 0;
|
|
204
|
+
skillCacheStats.currentSize = skillReadCache.size;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Test-only: temporarily override the cache capacity to exercise eviction logic.
|
|
209
|
+
* Always restore to `SKILL_CACHE_MAX_ENTRIES` after the test.
|
|
210
|
+
*/
|
|
211
|
+
export function _setSkillCacheMaxEntriesForTesting(max: number): void {
|
|
212
|
+
skillCacheStats.maxEntries = max;
|
|
159
213
|
}
|
|
160
214
|
|
|
161
215
|
function cachedSkillFresh(value: CachedSkillMarkdown): boolean {
|
|
@@ -181,8 +235,13 @@ function readSkillMarkdown(
|
|
|
181
235
|
if (!isValidSkillName(name)) return undefined;
|
|
182
236
|
const cacheKey = `${path.resolve(cwd)}:${name}`;
|
|
183
237
|
const cached = skillReadCache.get(cacheKey);
|
|
184
|
-
if (cached && cachedSkillFresh(cached))
|
|
238
|
+
if (cached && cachedSkillFresh(cached)) {
|
|
239
|
+
skillCacheStats.hits++;
|
|
240
|
+
return cached;
|
|
241
|
+
}
|
|
185
242
|
if (cached) skillReadCache.delete(cacheKey);
|
|
243
|
+
skillCacheStats.misses++;
|
|
244
|
+
skillCacheStats.currentSize = skillReadCache.size;
|
|
186
245
|
for (const entry of candidateSkillDirs(cwd)) {
|
|
187
246
|
try {
|
|
188
247
|
const relative = path.join(name, "SKILL.md");
|
|
@@ -74,6 +74,9 @@ function checkResultFile(manifest: TeamRunManifest, tasks: TeamTaskState[]): { f
|
|
|
74
74
|
const onlyCancelledOrSkipped = tasks.every((t) => t.status === "cancelled" || t.status === "skipped");
|
|
75
75
|
manifest.status = hasFailed ? "failed" : onlyCancelledOrSkipped ? "cancelled" : "completed";
|
|
76
76
|
// Persist manifest status change immediately to make checkResultFile self-contained.
|
|
77
|
+
// NOTE (OPT-02): Remains sync — reconcileStaleRun → checkResultFile is called from
|
|
78
|
+
// withRunLockSync in crash-recovery.ts and from sync reconcileOrphanedTempWorkspaces.
|
|
79
|
+
// Converting to async would require cascading through all callers including sync UI hooks.
|
|
77
80
|
saveRunManifest(manifest);
|
|
78
81
|
// Sync agent records even when tasks are already terminal
|
|
79
82
|
// (e.g., a previous reconcile fixed tasks but crashed before updating agents)
|
|
@@ -329,6 +332,8 @@ export function reconcileStaleRun(manifest: TeamRunManifest, tasks: TeamTaskStat
|
|
|
329
332
|
// checkResultFile sets manifest.status='completed' and saves it,
|
|
330
333
|
// but we re-save to ensure the completed status is persisted
|
|
331
334
|
// before returning (avoids TOCTOU where caller might re-read stale data)
|
|
335
|
+
// NOTE (OPT-02): Remains sync — reconcileStaleRun is called from withRunLockSync in
|
|
336
|
+
// crash-recovery.ts and from sync reconcileOrphanedTempWorkspaces; cannot await here.
|
|
332
337
|
saveRunManifest(manifest);
|
|
333
338
|
return {
|
|
334
339
|
runId,
|
|
@@ -4,10 +4,22 @@ import * as fs from "node:fs";
|
|
|
4
4
|
* Read the tail of a file, capped at maxBytes.
|
|
5
5
|
* If the file exceeds maxBytes, reads only the last maxBytes and snaps
|
|
6
6
|
* to the nearest newline boundary to avoid partial JSONL lines.
|
|
7
|
+
*
|
|
8
|
+
* Falls back to `fallbackContent` when the file is missing OR empty. The
|
|
9
|
+
* empty-file case matters because `appendTranscript` is fire-and-forget
|
|
10
|
+
* async in the mock path (commit e316a36) — the file is created on disk
|
|
11
|
+
* (existsSync returns true) before the actual write completes, so a
|
|
12
|
+
* subsequent tail-read can observe size=0. Returning "" in that case would
|
|
13
|
+
* silently drop the mock's stdout, breaking downstream parsers that depend
|
|
14
|
+
* on a non-empty transcript (e.g. adaptive-plan JSON extraction in
|
|
15
|
+
* implementation-fanout.test.ts). Falling back to `fallbackContent` keeps
|
|
16
|
+
* the existing fallback semantics: "if the file isn't usable, use what
|
|
17
|
+
* the caller already has."
|
|
7
18
|
*/
|
|
8
19
|
export function tailReadWithLineSnap(filePath: string, maxBytes: number, fallbackContent: string): string {
|
|
9
20
|
if (!fs.existsSync(filePath)) return fallbackContent;
|
|
10
21
|
const stat = fs.statSync(filePath);
|
|
22
|
+
if (stat.size === 0) return fallbackContent;
|
|
11
23
|
if (stat.size <= maxBytes) return fs.readFileSync(filePath, "utf-8");
|
|
12
24
|
const fd = fs.openSync(filePath, "r");
|
|
13
25
|
try {
|
|
@@ -7,8 +7,8 @@ import { errors } from "../errors.ts";
|
|
|
7
7
|
import { appendHookEvent, executeHook } from "../hooks/registry.ts";
|
|
8
8
|
import { writeArtifact } from "../state/artifact-store.ts";
|
|
9
9
|
import { appendEventAsync, appendEventBuffered, appendEventFireAndForget } from "../state/event-log.ts";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { withRunLock } from "../state/locks.ts";
|
|
11
|
+
import { saveRunManifestAsync } from "../state/state-store.ts";
|
|
12
12
|
import { createTaskClaim } from "../state/task-claims.ts";
|
|
13
13
|
import type {
|
|
14
14
|
ArtifactDescriptor,
|
|
@@ -83,6 +83,36 @@ import {
|
|
|
83
83
|
// Register the submit_result tool handler so subprocess events can extract yield data.
|
|
84
84
|
registerYieldTool();
|
|
85
85
|
|
|
86
|
+
/** Async helper for writing steering events — fire-and-forget for non-blocking writes. */
|
|
87
|
+
async function appendSteeringAsync(steeringDir: string, taskId: string, steers: string[]): Promise<void> {
|
|
88
|
+
try {
|
|
89
|
+
await fs.promises.mkdir(steeringDir, { recursive: true });
|
|
90
|
+
const steeringPath = `${steeringDir}/${taskId}.jsonl`;
|
|
91
|
+
const lines = steers
|
|
92
|
+
.map(
|
|
93
|
+
(msg) =>
|
|
94
|
+
JSON.stringify({
|
|
95
|
+
type: "steer",
|
|
96
|
+
message: msg,
|
|
97
|
+
ts: new Date().toISOString(),
|
|
98
|
+
}) + "\n",
|
|
99
|
+
)
|
|
100
|
+
.join("");
|
|
101
|
+
await fs.promises.appendFile(steeringPath, lines, "utf-8");
|
|
102
|
+
} catch (error) {
|
|
103
|
+
logInternalError("task-runner.steering-write-failed", error as Error, `taskId=${taskId}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Async helper for writing background logs — fire-and-forget for non-blocking writes. */
|
|
108
|
+
async function appendBackgroundLogAsync(bgLogPath: string, eventLine: string): Promise<void> {
|
|
109
|
+
try {
|
|
110
|
+
await fs.promises.appendFile(bgLogPath, `${eventLine}\n`, "utf-8");
|
|
111
|
+
} catch (error) {
|
|
112
|
+
logInternalError("task-runner.background-log-write-failed", error as Error, `path=${bgLogPath}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
86
116
|
export interface TaskRunnerInput {
|
|
87
117
|
manifest: TeamRunManifest;
|
|
88
118
|
tasks: TeamTaskState[];
|
|
@@ -413,7 +443,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
413
443
|
// Ensure transcripts/ subdirectory exists before child-pi appends
|
|
414
444
|
// to it. appendTranscript uses O_APPEND (no mkdir) for security,
|
|
415
445
|
// so the caller must create the directory.
|
|
416
|
-
fs.
|
|
446
|
+
await fs.promises.mkdir(path.join(manifest.artifactsRoot, "transcripts"), {
|
|
417
447
|
recursive: true,
|
|
418
448
|
});
|
|
419
449
|
const model = attemptModels[i];
|
|
@@ -460,18 +490,8 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
460
490
|
({ task, tasks } = checkpointTask(manifest, tasks, task, "child-spawned", pid));
|
|
461
491
|
if (task.pendingSteers?.length) {
|
|
462
492
|
const steeringDir = `${manifest.artifactsRoot}/steering`;
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
for (const msg of task.pendingSteers) {
|
|
466
|
-
fs.appendFileSync(
|
|
467
|
-
steeringPath,
|
|
468
|
-
JSON.stringify({
|
|
469
|
-
type: "steer",
|
|
470
|
-
message: msg,
|
|
471
|
-
ts: new Date().toISOString(),
|
|
472
|
-
}) + "\n",
|
|
473
|
-
);
|
|
474
|
-
}
|
|
493
|
+
// Fire-and-forget async write for steering events
|
|
494
|
+
void appendSteeringAsync(steeringDir, task.id, task.pendingSteers);
|
|
475
495
|
task.pendingSteers = [];
|
|
476
496
|
tasks = persistSingleTaskUpdate(manifest, tasks, task);
|
|
477
497
|
}
|
|
@@ -533,7 +553,8 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
533
553
|
const bgLogPath = `${manifest.stateRoot}/background.log`;
|
|
534
554
|
const eventLine =
|
|
535
555
|
typeof event === "object" && !Array.isArray(event) ? JSON.stringify(event) : String(event);
|
|
536
|
-
|
|
556
|
+
// Fire-and-forget async write for background log
|
|
557
|
+
void appendBackgroundLogAsync(bgLogPath, eventLine);
|
|
537
558
|
}
|
|
538
559
|
// Always keep in-memory agentProgress fresh (cheap) so the UI/events see
|
|
539
560
|
// the latest progress, but THROTTLE the disk persist. Previously this
|
|
@@ -1201,9 +1222,9 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
|
|
|
1201
1222
|
// stale manifest and overwrite this task's freshly-written artifacts, silently
|
|
1202
1223
|
// losing them. persistSingleTaskUpdate is re-entrance-safe (runLockHeldByUs guard),
|
|
1203
1224
|
// so nesting it inside this lock is a no-op re-acquire, not a deadlock.
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1225
|
+
tasks = await withRunLock(manifest, async () => {
|
|
1226
|
+
await saveRunManifestAsync(manifest);
|
|
1227
|
+
return persistSingleTaskUpdate(manifest, tasks, task);
|
|
1207
1228
|
});
|
|
1208
1229
|
upsertCrewAgent(manifest, recordFromTask(manifest, task, runtimeKind));
|
|
1209
1230
|
// Execute task_result hook before emitting terminal event
|
|
@@ -12,7 +12,7 @@ import { hashArtifactContent as hashContent, writeArtifact } from "../state/arti
|
|
|
12
12
|
import { appendEvent, appendEventAsync, appendEventBuffered, appendEventFireAndForget, flushEventLogBuffer } from "../state/event-log.ts";
|
|
13
13
|
import { HealthStore } from "../state/health-store.ts";
|
|
14
14
|
import { withRunLock } from "../state/locks.ts";
|
|
15
|
-
import { loadRunManifestById,
|
|
15
|
+
import { loadRunManifestById, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/state-store.ts";
|
|
16
16
|
import type { ArtifactDescriptor, PolicyDecision, TaskAttemptState, TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
17
17
|
import { aggregateUsage, formatTokens, formatUsage } from "../state/usage.ts";
|
|
18
18
|
import type { TeamConfig } from "../teams/team-config.ts";
|
|
@@ -20,7 +20,7 @@ import { logInternalError } from "../utils/internal-error.ts";
|
|
|
20
20
|
import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
|
|
21
21
|
import { checkBranchFreshness } from "../worktree/branch-freshness.ts";
|
|
22
22
|
import { buildSyntheticTerminalEvidence, CrewCancellationError, cancellationReasonFromSignal } from "./cancellation.ts";
|
|
23
|
-
import { buildDispatchUnits, planCoalescedGroups } from "./coalesce-tasks.ts";
|
|
23
|
+
import { buildDispatchUnits, type DispatchUnit, planCoalescedGroups } from "./coalesce-tasks.ts";
|
|
24
24
|
import { resolveBatchConcurrency } from "./concurrency.ts";
|
|
25
25
|
import { readCrewAgents, saveCrewAgents } from "./crew-agent-records.ts";
|
|
26
26
|
import type { CrewRuntimeKind } from "./crew-agent-runtime.ts";
|
|
@@ -30,7 +30,6 @@ import { effectivenessPolicyDecision, evaluateRunEffectiveness, formatRunEffecti
|
|
|
30
30
|
import { applyGoalAchievement, assessGoalAchievement } from "./goal-achievement.ts";
|
|
31
31
|
import { deliverGroupJoin, resolveGroupJoinMode } from "./group-join.ts";
|
|
32
32
|
import { terminateLiveAgentsForRun } from "./live-agent-manager.ts";
|
|
33
|
-
import { mapConcurrent } from "./parallel-utils.ts";
|
|
34
33
|
import { filterReadyByWriteOverlap } from "./path-overlap.ts";
|
|
35
34
|
import { evaluateCrewPolicy, summarizePolicyDecisions } from "./policy-engine.ts";
|
|
36
35
|
import { buildRecoveryLedger, shouldRerunFailedTask } from "./recovery-recipes.ts";
|
|
@@ -618,7 +617,7 @@ function isMutatingTask(task: TeamTaskState): boolean {
|
|
|
618
617
|
return permissionForRole(task.role) !== "read_only";
|
|
619
618
|
}
|
|
620
619
|
|
|
621
|
-
function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskState[]): TeamRunManifest {
|
|
620
|
+
async function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskState[]): Promise<TeamRunManifest> {
|
|
622
621
|
if (manifest.planApproval) return manifest;
|
|
623
622
|
const assessTask = tasks.find((task) => task.stepId === "assess" && task.status === "completed");
|
|
624
623
|
// ROADMAP T1.2: for non-adaptive workflows, fall back to the most recent
|
|
@@ -637,7 +636,7 @@ function ensurePlanApprovalRequested(manifest: TeamRunManifest, tasks: TeamTaskS
|
|
|
637
636
|
planArtifactPath: planTask?.resultArtifact?.path,
|
|
638
637
|
},
|
|
639
638
|
};
|
|
640
|
-
|
|
639
|
+
await saveRunManifestAsync(updated);
|
|
641
640
|
appendEvent(updated.eventsPath, {
|
|
642
641
|
type: "plan.approval_required",
|
|
643
642
|
runId: updated.runId,
|
|
@@ -745,7 +744,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
745
744
|
if (input.budgetAbort !== undefined) manifest.budgetAbort = input.budgetAbort;
|
|
746
745
|
if (input.budgetUnlimited !== undefined) manifest.budgetUnlimited = input.budgetUnlimited;
|
|
747
746
|
if (manifest.budgetTotal !== undefined && manifest.budgetTotal > 0 && !manifest.budgetUnlimited) {
|
|
748
|
-
|
|
747
|
+
await saveRunManifestAsync(manifest);
|
|
749
748
|
}
|
|
750
749
|
|
|
751
750
|
void registerRunPromise(manifest.runId);
|
|
@@ -773,7 +772,7 @@ export async function executeTeamRun(input: ExecuteTeamRunInput): Promise<{ mani
|
|
|
773
772
|
if (gaApplied.manifest !== result.manifest) {
|
|
774
773
|
result.manifest = gaApplied.manifest;
|
|
775
774
|
try {
|
|
776
|
-
|
|
775
|
+
await saveRunManifestAsync(result.manifest);
|
|
777
776
|
} catch (persistError) {
|
|
778
777
|
logInternalError(
|
|
779
778
|
"team-runner.goalAchievement.persist",
|
|
@@ -948,9 +947,9 @@ async function executeTeamRunCore(
|
|
|
948
947
|
const canInjectAdaptivePlan = workflow.name === "implementation";
|
|
949
948
|
let adaptivePlanInjected = false;
|
|
950
949
|
let adaptivePlanMissing = false;
|
|
951
|
-
const attemptAdaptivePlan = () => {
|
|
950
|
+
const attemptAdaptivePlan = async () => {
|
|
952
951
|
if (!canInjectAdaptivePlan || adaptivePlanInjected || adaptivePlanMissing) return { injected: false, missing: false };
|
|
953
|
-
const adaptivePlan = injectAdaptivePlanIfReady({
|
|
952
|
+
const adaptivePlan = await injectAdaptivePlanIfReady({
|
|
954
953
|
manifest,
|
|
955
954
|
tasks,
|
|
956
955
|
workflow,
|
|
@@ -965,7 +964,7 @@ async function executeTeamRunCore(
|
|
|
965
964
|
missing: adaptivePlan.missingPlan,
|
|
966
965
|
};
|
|
967
966
|
};
|
|
968
|
-
const initialAdaptive = attemptAdaptivePlan();
|
|
967
|
+
const initialAdaptive = await attemptAdaptivePlan();
|
|
969
968
|
if (initialAdaptive.missing) {
|
|
970
969
|
tasks = markBlocked(tasks, "Adaptive planner did not produce a valid subagent plan.");
|
|
971
970
|
await saveRunTasksAsync(manifest, tasks);
|
|
@@ -973,13 +972,13 @@ async function executeTeamRunCore(
|
|
|
973
972
|
return { manifest, tasks };
|
|
974
973
|
}
|
|
975
974
|
if (initialAdaptive.injected) {
|
|
976
|
-
manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? ensurePlanApprovalRequested(manifest, tasks) : manifest;
|
|
975
|
+
manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? await ensurePlanApprovalRequested(manifest, tasks) : manifest;
|
|
977
976
|
queueIndex = buildTaskGraphIndex(tasks);
|
|
978
977
|
} else if (
|
|
979
978
|
requiresPlanApproval(workflow, input.runtimeConfig) &&
|
|
980
979
|
(hasPendingMutatingAdaptiveTask(tasks) || hasPendingMutatingTaskAtBoundary(tasks))
|
|
981
980
|
) {
|
|
982
|
-
manifest = ensurePlanApprovalRequested(manifest, tasks);
|
|
981
|
+
manifest = await ensurePlanApprovalRequested(manifest, tasks);
|
|
983
982
|
}
|
|
984
983
|
if (manifest.planApproval?.status === "cancelled") {
|
|
985
984
|
tasks = cancelPlanTasks(tasks, "Plan approval was cancelled.");
|
|
@@ -1003,7 +1002,13 @@ async function executeTeamRunCore(
|
|
|
1003
1002
|
);
|
|
1004
1003
|
let wfMachine = createWorkflowStateMachine(workflowPhases);
|
|
1005
1004
|
|
|
1006
|
-
|
|
1005
|
+
// ── OPT-01 streaming dispatch: track in-flight dispatch units so a new
|
|
1006
|
+
// task can be dispatched as soon as a slot frees, without waiting for
|
|
1007
|
+
// the entire batch to complete. Each entry maps a unit key (singleton
|
|
1008
|
+
// task ID or coalesced-group ID) to the in-flight promise + member IDs. ──
|
|
1009
|
+
const pendingUnits = new Map<string, { taskIds: string[]; promise: Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> }>();
|
|
1010
|
+
|
|
1011
|
+
while (tasks.some((task) => task.status === "queued") || pendingUnits.size > 0) {
|
|
1007
1012
|
if (input.signal?.aborted) {
|
|
1008
1013
|
const cancelReason = cancellationReasonFromSignal(input.signal);
|
|
1009
1014
|
const message = `${cancelReason.message} (${cancelReason.code})`;
|
|
@@ -1202,26 +1207,39 @@ async function executeTeamRunCore(
|
|
|
1202
1207
|
},
|
|
1203
1208
|
});
|
|
1204
1209
|
}
|
|
1210
|
+
// ── OPT-01 streaming dispatch: exclude tasks already in-flight, limit
|
|
1211
|
+
// new dispatches to available concurrency slots. ──
|
|
1212
|
+
const inFlightTaskIds = new Set<string>();
|
|
1213
|
+
for (const pendingUnit of pendingUnits.values()) {
|
|
1214
|
+
for (const taskId of pendingUnit.taskIds) inFlightTaskIds.add(taskId);
|
|
1215
|
+
}
|
|
1216
|
+
const slotsAvailable = Math.max(0, concurrency.maxConcurrent - pendingUnits.size);
|
|
1205
1217
|
const approvalPending = isPlanApprovalPending(manifest);
|
|
1206
|
-
const
|
|
1218
|
+
const dispatchableReady = serializedReady.filter((id) => !inFlightTaskIds.has(id));
|
|
1219
|
+
const readyIds = approvalPending ? dispatchableReady : dispatchableReady.slice(0, slotsAvailable);
|
|
1207
1220
|
const candidateBatch = readyIds
|
|
1208
1221
|
.map((id) => tasks.find((task) => task.id === id))
|
|
1209
1222
|
.filter((task): task is TeamTaskState => Boolean(task));
|
|
1210
1223
|
const readyBatch = approvalPending
|
|
1211
|
-
? candidateBatch.filter((task) => !isMutatingTask(task)).slice(0,
|
|
1224
|
+
? candidateBatch.filter((task) => !isMutatingTask(task)).slice(0, slotsAvailable)
|
|
1212
1225
|
: candidateBatch;
|
|
1213
1226
|
if (readyBatch.length === 0) {
|
|
1214
|
-
if (
|
|
1227
|
+
if (pendingUnits.size > 0) {
|
|
1228
|
+
// Tasks are in-flight — skip dispatch and proceed to wait phase.
|
|
1229
|
+
// (No return; code falls through to the dispatch section which is
|
|
1230
|
+
// a no-op with an empty readyBatch, then reaches the wait phase.)
|
|
1231
|
+
} else if (approvalPending && candidateBatch.some(isMutatingTask)) {
|
|
1215
1232
|
await saveRunTasksAsync(manifest, tasks);
|
|
1216
1233
|
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
1217
1234
|
manifest = updateRunStatus(manifest, "blocked", "Plan approval required before mutating implementation tasks run.");
|
|
1218
1235
|
return { manifest, tasks };
|
|
1236
|
+
} else {
|
|
1237
|
+
tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
|
|
1238
|
+
await saveRunTasksAsync(manifest, tasks);
|
|
1239
|
+
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
1240
|
+
manifest = updateRunStatus(manifest, "blocked", "No ready queued task.");
|
|
1241
|
+
return { manifest, tasks };
|
|
1219
1242
|
}
|
|
1220
|
-
tasks = markBlocked(tasks, "No ready queued task; dependency graph may be invalid.");
|
|
1221
|
-
await saveRunTasksAsync(manifest, tasks);
|
|
1222
|
-
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
1223
|
-
manifest = updateRunStatus(manifest, "blocked", "No ready queued task.");
|
|
1224
|
-
return { manifest, tasks };
|
|
1225
1243
|
}
|
|
1226
1244
|
|
|
1227
1245
|
// 2.2 caller migration: batch progress is high-frequency informational (M7 wire).
|
|
@@ -1311,7 +1329,10 @@ async function executeTeamRunCore(
|
|
|
1311
1329
|
);
|
|
1312
1330
|
}
|
|
1313
1331
|
|
|
1314
|
-
|
|
1332
|
+
// ── OPT-01 streaming dispatch: dispatch each unit into pendingUnits
|
|
1333
|
+
// instead of awaiting the entire batch via mapConcurrent. Each unit's
|
|
1334
|
+
// promise is stored so we can Promise.race on the next iteration. ──
|
|
1335
|
+
const dispatchUnit = async (unit: DispatchUnit): Promise<{ manifest: TeamRunManifest; tasks: TeamTaskState[] }> => {
|
|
1315
1336
|
// M6 real dispatch path: single worker for N tasks.
|
|
1316
1337
|
if (unit.kind === "group") {
|
|
1317
1338
|
const groupTasks = unit.group.tasks;
|
|
@@ -1533,30 +1554,52 @@ async function executeTeamRunCore(
|
|
|
1533
1554
|
}),
|
|
1534
1555
|
);
|
|
1535
1556
|
}
|
|
1536
|
-
}
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
// empty but queued remain) is caught by the readyBatch.length===0
|
|
1546
|
-
// guard above which marks the run "blocked".
|
|
1547
|
-
continue;
|
|
1548
|
-
}
|
|
1549
|
-
// FIX: Filter out undefined entries from partial results when error occurred
|
|
1550
|
-
// during parallel execution. Other workers may have written partial results
|
|
1551
|
-
// before one threw. Results may be partial - some tasks in-flight at error
|
|
1552
|
-
// time will not have entries in the results array.
|
|
1553
|
-
const validResults = results.filter((item): item is NonNullable<typeof item> => item !== undefined);
|
|
1554
|
-
// Guard: if ALL parallel workers threw before returning, validResults is empty.
|
|
1555
|
-
// at(-1)! would crash. Mark the run failed rather than crashing.
|
|
1556
|
-
if (validResults.length === 0) {
|
|
1557
|
-
manifest = updateRunStatus(manifest, "failed", "All parallel tasks failed catastrophically.");
|
|
1558
|
-
return { manifest, tasks };
|
|
1557
|
+
};
|
|
1558
|
+
// ── OPT-01 streaming dispatch: dispatch units into pendingUnits ──
|
|
1559
|
+
for (const unit of dispatchUnits) {
|
|
1560
|
+
const unitKey = unit.kind === "singleton" ? unit.taskId : unit.group.id;
|
|
1561
|
+
const unitTaskIds = unit.kind === "singleton" ? [unit.taskId] : unit.group.tasks.map((t) => t.id);
|
|
1562
|
+
pendingUnits.set(unitKey, {
|
|
1563
|
+
taskIds: unitTaskIds,
|
|
1564
|
+
promise: dispatchUnit(unit),
|
|
1565
|
+
});
|
|
1559
1566
|
}
|
|
1567
|
+
|
|
1568
|
+
// ── OPT-01 wait phase: if no units are in-flight (e.g. all ready tasks
|
|
1569
|
+
// were hook-skipped or readyBatch was empty), re-loop to re-evaluate. ──
|
|
1570
|
+
if (pendingUnits.size === 0) continue;
|
|
1571
|
+
|
|
1572
|
+
// Wait for ONE in-flight unit to complete. Promise.race returns as soon
|
|
1573
|
+
// as the first wrapper resolves — others remain pending in pendingUnits
|
|
1574
|
+
// and are re-raced on the next iteration.
|
|
1575
|
+
const settled = await Promise.race(
|
|
1576
|
+
[...pendingUnits.entries()].map(async ([key, pending]) => {
|
|
1577
|
+
try {
|
|
1578
|
+
const result = await pending.promise;
|
|
1579
|
+
return {
|
|
1580
|
+
unitKey: key,
|
|
1581
|
+
result: result as { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined,
|
|
1582
|
+
error: undefined as Error | undefined,
|
|
1583
|
+
};
|
|
1584
|
+
} catch (error) {
|
|
1585
|
+
return { unitKey: key, result: undefined, error: error instanceof Error ? error : new Error(String(error)) };
|
|
1586
|
+
}
|
|
1587
|
+
}),
|
|
1588
|
+
);
|
|
1589
|
+
const completedUnit = pendingUnits.get(settled.unitKey)!;
|
|
1590
|
+
pendingUnits.delete(settled.unitKey);
|
|
1591
|
+
|
|
1592
|
+
// Build the single result to merge. On rejection, synthesize a failed
|
|
1593
|
+
// result so the run continues (mirrors the old validResults guard).
|
|
1594
|
+
const resultToMerge: { manifest: TeamRunManifest; tasks: TeamTaskState[] } = settled.result ?? {
|
|
1595
|
+
manifest,
|
|
1596
|
+
tasks: tasks.map((t) =>
|
|
1597
|
+
completedUnit.taskIds.includes(t.id)
|
|
1598
|
+
? { ...t, status: "failed" as const, error: settled.error!.message, finishedAt: new Date().toISOString() }
|
|
1599
|
+
: t,
|
|
1600
|
+
),
|
|
1601
|
+
};
|
|
1602
|
+
const validResults = [resultToMerge];
|
|
1560
1603
|
// Reconstruct manifest from the last worker's snapshot. The .artifacts field
|
|
1561
1604
|
// is re-merged from both the team-runner's in-memory state and all workers'
|
|
1562
1605
|
// snapshots, so artifact writes by task-runner (which individually save manifest
|
|
@@ -1722,7 +1765,7 @@ async function executeTeamRunCore(
|
|
|
1722
1765
|
}
|
|
1723
1766
|
}
|
|
1724
1767
|
|
|
1725
|
-
const cancelledResult =
|
|
1768
|
+
const cancelledResult = resultToMerge.manifest.status === "cancelled" ? resultToMerge : undefined;
|
|
1726
1769
|
if (cancelledResult || input.signal?.aborted) {
|
|
1727
1770
|
const reason = input.signal?.aborted ? cancellationReasonFromSignal(input.signal) : undefined;
|
|
1728
1771
|
const message = reason?.message ?? cancelledResult?.manifest.summary ?? "Run cancelled during task execution.";
|
|
@@ -1761,7 +1804,7 @@ async function executeTeamRunCore(
|
|
|
1761
1804
|
return { manifest, tasks: reCancelledTasks };
|
|
1762
1805
|
}
|
|
1763
1806
|
queueIndex = buildTaskGraphIndex(tasks);
|
|
1764
|
-
const injectedAfterBatch = attemptAdaptivePlan();
|
|
1807
|
+
const injectedAfterBatch = await attemptAdaptivePlan();
|
|
1765
1808
|
if (injectedAfterBatch.missing) {
|
|
1766
1809
|
tasks = markBlocked(tasks, "Adaptive planner did not produce a valid subagent plan.");
|
|
1767
1810
|
await saveRunTasksAsync(manifest, tasks);
|
|
@@ -1770,13 +1813,13 @@ async function executeTeamRunCore(
|
|
|
1770
1813
|
return { manifest, tasks };
|
|
1771
1814
|
}
|
|
1772
1815
|
if (injectedAfterBatch.injected) {
|
|
1773
|
-
manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? ensurePlanApprovalRequested(manifest, tasks) : manifest;
|
|
1816
|
+
manifest = requiresPlanApproval(workflow, input.runtimeConfig) ? await ensurePlanApprovalRequested(manifest, tasks) : manifest;
|
|
1774
1817
|
queueIndex = buildTaskGraphIndex(tasks);
|
|
1775
1818
|
} else if (
|
|
1776
1819
|
requiresPlanApproval(workflow, input.runtimeConfig) &&
|
|
1777
1820
|
(hasPendingMutatingAdaptiveTask(tasks) || hasPendingMutatingTaskAtBoundary(tasks))
|
|
1778
1821
|
) {
|
|
1779
|
-
manifest = ensurePlanApprovalRequested(manifest, tasks);
|
|
1822
|
+
manifest = await ensurePlanApprovalRequested(manifest, tasks);
|
|
1780
1823
|
}
|
|
1781
1824
|
if (manifest.planApproval?.status === "cancelled") {
|
|
1782
1825
|
tasks = cancelPlanTasks(tasks, "Plan approval was cancelled.");
|
|
@@ -1787,17 +1830,17 @@ async function executeTeamRunCore(
|
|
|
1787
1830
|
}
|
|
1788
1831
|
await saveRunTasksAsync(manifest, tasks);
|
|
1789
1832
|
saveCrewAgents(manifest, recordsForMaterializedTasks(manifest, tasks, runtimeKind));
|
|
1790
|
-
const completedBatch = tasks.filter((t) =>
|
|
1833
|
+
const completedBatch = tasks.filter((t) => completedUnit.taskIds.includes(t.id));
|
|
1791
1834
|
const batchArtifact = writeArtifact(manifest.artifactsRoot, {
|
|
1792
1835
|
kind: "summary",
|
|
1793
|
-
relativePath: `batches/${
|
|
1836
|
+
relativePath: `batches/${completedUnit.taskIds.join("+")}.md`,
|
|
1794
1837
|
producer: "team-runner",
|
|
1795
1838
|
content: aggregateTaskOutputs(completedBatch, manifest),
|
|
1796
1839
|
});
|
|
1797
1840
|
const groupDelivery = deliverGroupJoin({
|
|
1798
1841
|
manifest,
|
|
1799
1842
|
mode: resolveGroupJoinMode(input.runtimeConfig),
|
|
1800
|
-
batch:
|
|
1843
|
+
batch: completedBatch,
|
|
1801
1844
|
allTasks: tasks,
|
|
1802
1845
|
});
|
|
1803
1846
|
manifest = {
|
|
@@ -382,6 +382,10 @@ export interface TeamToolParamsValue {
|
|
|
382
382
|
cron?: string;
|
|
383
383
|
interval?: number;
|
|
384
384
|
once?: string | number;
|
|
385
|
+
|
|
386
|
+
// schedule sub-actions (removal/toggle/update of an existing job)
|
|
387
|
+
subAction?: string;
|
|
388
|
+
jobId?: string;
|
|
385
389
|
/** Mark certain bash commands as excludeFromContext to reduce context tokens (default: false). */
|
|
386
390
|
excludeContextBash?: boolean;
|
|
387
391
|
/** Total token budget for the run. When set, enables budget tracking (minimum 1000). */
|
|
@@ -11,7 +11,11 @@ function hashContent(content: string): string {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
const RETRYABLE_RENAME_CODES = new Set(["EPERM", "EBUSY", "EACCES"]);
|
|
14
|
-
|
|
14
|
+
// EEXIST is retryable: two concurrent async saves (OPT-02 async saveRunManifest)
|
|
15
|
+
// can race on unlink+link — the second link() hits EEXIST because the first
|
|
16
|
+
// already created the destination. The retry loop unlinks and re-links,
|
|
17
|
+
// resolving the race. Exponential backoff + jitter prevents starvation.
|
|
18
|
+
const RETRYABLE_LINK_CODES = new Set(["EPERM", "EBUSY", "EACCES", "ENOENT", "EEXIST"]);
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
21
|
* Symlink-safe file write guard (caveman-inspired).
|