pi-better-subagents 0.6.0 → 0.7.1
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/README.md +1 -1
- package/child-incidents.ts +8 -6
- package/docs/failure-observations.md +3 -1
- package/failures.ts +94 -6
- package/incident-model.ts +18 -11
- package/index.ts +47 -18
- package/output-payload.ts +4 -13
- package/package.json +1 -1
- package/permission-policy.ts +9 -3
- package/registry.ts +19 -2
- package/shared-failure-observations.ts +70 -21
- package/shared-log-utils.ts +6 -12
- package/shared-navigator.ts +224 -125
- package/shared-sandbox-core.ts +194 -22
- package/shared-task-apply-patch.ts +409 -0
- package/shared-task-files.ts +69 -7
- package/shared-task-sandbox.ts +12 -0
- package/shared-task-tools.ts +111 -0
- package/subagent-tools.ts +95 -0
- package/task-guard.ts +42 -7
- package/task-policy.ts +29 -1
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ Try it for one run:
|
|
|
30
30
|
pi -e npm:pi-better-subagents
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
-
Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or newer. `/sandbox` controls the independent Subagents profile, which each launch freezes. Pi handles its own startup, authentication, and provider connection; task tools obey the selected file, command, and network permissions. Outside project defaults to Write: tasks write across home and temp but can remove files outside the workspace only in temp, hidden home directories, and worktree folders (Linux keeps ordinary home folders read-only instead).
|
|
33
|
+
Linux confinement requires a usable `bubblewrap` backend and Pi SDK 0.82.1 or newer. `/sandbox` controls the independent Subagents profile, which each launch freezes. Pi handles its own startup, authentication, and provider connection; task tools obey the selected file, command, and network permissions. Outside project defaults to Write: tasks write across home and temp but can remove files outside the workspace only in temp, hidden home directories, and worktree folders (Linux keeps ordinary home folders read-only instead). Confined children admit `read`, `write`, `edit`, and `bash`, the guarded `apply_patch` (Codex patches that follow the file rules), and the trusted tools ticked in `/sandbox` → Subagents · Tools (default `web_fetch` and `web_search`), which run outside the file rules. Other requested tools are reported as unavailable, with the reason. See [usage notes](https://github.com/1aboveio/pi-better-harness/blob/main/packages/pi-better-subagents/docs/usage.md#write-sandbox) for the runtime boundary and supported configurations.
|
|
34
34
|
|
|
35
35
|
## Delegation Modes
|
|
36
36
|
|
package/child-incidents.ts
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import type { BashOperations, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
-
|
|
15
|
-
import {
|
|
16
|
-
|
|
14
|
+
// The intent validator is shared with background tasks through the vendored failure-observations module (#325).
|
|
15
|
+
import { emptyFailureState, reduceFailure, INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES, readCommandIntent, withoutAbsentIntent,
|
|
16
|
+
type FailureEvent, type FailureState } from "./shared-failure-observations.ts";
|
|
17
|
+
import { DISPOSITION_TOOL, describeOpenTargets, foldToolEnd, foldToolStart,
|
|
18
|
+
newIncidentModel, readDispositionRequest, resolveDisposition, type IncidentModel, type IncidentSink } from "./incident-model.ts";
|
|
17
19
|
|
|
18
20
|
const { createBashToolDefinition } = PiCodingAgent;
|
|
19
21
|
|
|
@@ -28,10 +30,10 @@ const intentProperties = {
|
|
|
28
30
|
description: "Optional stable id for one logical operation. Reuse it on a modified retry (changed scope, timeout, or flags) so a later success recovers the earlier failure." },
|
|
29
31
|
attemptId: { ...nullable({ type: "string", pattern: INTENT_ID_PATTERN }),
|
|
30
32
|
description: "Optional unique id for this execution, for use as evidence in failure_disposition." },
|
|
31
|
-
expectedExitCodes: { ...nullable({ type: "array", items: { type: "integer", minimum:
|
|
33
|
+
expectedExitCodes: { ...nullable({ type: "array", items: { type: "integer", minimum: 0, maximum: 255 }, minItems: 1, maxItems: MAX_EXPECTED_EXIT_CODES }),
|
|
32
34
|
// Distinctness is enforced by readCommandIntent, not `uniqueItems`: OpenAI rejects that keyword
|
|
33
35
|
// in function schemas, which failed every confined child's first request.
|
|
34
|
-
description: "Optional distinct non-zero exit codes that are intentional for this command (for example [1] for an rg/grep no-match or git diff --exit-code probe). Declared before the command runs; the final shell exit code is what is classified." },
|
|
36
|
+
description: "Optional distinct non-zero exit codes that are intentional for this command (for example [1] for an rg/grep no-match or git diff --exit-code probe). 0 is allowed and ignored: exit 0 is already success. Declared before the command runs; the final shell exit code is what is classified." },
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
/**
|
|
@@ -95,7 +97,7 @@ export function intentBashDefinition(cwd: string, operations: BashOperations) {
|
|
|
95
97
|
if (error) throw new Error(`Invalid command intent: ${error}. The command was not run.`);
|
|
96
98
|
if (intent.attemptId) {
|
|
97
99
|
const reused = sessionToolCalls(ctx).some((row) => row.type === "tool_execution_start" && row.toolName === "bash" &&
|
|
98
|
-
row.toolCallId !== toolCallId && (row.args as any)?.attemptId === intent.attemptId);
|
|
100
|
+
row.toolCallId !== toolCallId && (withoutAbsentIntent(row.args) as any)?.attemptId === intent.attemptId);
|
|
99
101
|
if (reused) throw new Error(`Invalid command intent: attemptId ${intent.attemptId} was already used. The command was not run.`);
|
|
100
102
|
}
|
|
101
103
|
const { operationId: _o, attemptId: _a, expectedExitCodes: _e, ...command } = params ?? {};
|
|
@@ -47,9 +47,11 @@ Automatic recovery still requires an exact retry: the same tool name, arguments,
|
|
|
47
47
|
|
|
48
48
|
Sandboxed children (the task runtime) get two structured additions. The parent honours them only for runs it launched on the task runtime: `meta.taskRuntime` and a parent-authored provenance record written before the child starts, outside the run directory, where the confined child cannot write. For any other run, intent-looking arguments are ordinary arguments and `failure_disposition` calls are ignored. If the metadata cannot be read for a moment, the parent waits and reads it again rather than falling back to the exact rule for the rest of the run. The wait is bounded: when the run ends, or after 30 seconds, the log is scanned under the exact-retry rule so real failures stay visible, with an `Observation incomplete` note that the metadata could not be read.
|
|
49
49
|
|
|
50
|
+
Trust is decided once per scan, when the scan begins. A running trusted run whose metadata later becomes unreadable (a corrupt `meta.json`, say) keeps its trusted scan for as long as the parent keeps that scan in memory. Only after a parent restart, a `/reload`, or an eviction from the scan cache does the bounded wait apply again, and the scan after it uses the exact-retry rule with the same note. When the metadata is readable again, a later trusted scan closes the note once the incidents that the exact-rule scan left unresolved are resolved. Failures from the trusted period never hold it open, however often the scan is rebuilt.
|
|
51
|
+
|
|
50
52
|
- `bash` accepts optional `operationId`, `attemptId`, and `expectedExitCodes`, validated before the command runs.
|
|
51
53
|
- `operationId` names one logical operation across modified retries. A later success with the same `operationId` (a changed scope, timeout, or flag) recovers the earlier failure automatically. Without it, the exact rule applies. `attemptId` names one execution for use as evidence and never changes operation identity.
|
|
52
|
-
- `expectedExitCodes` (distinct integers 1–255) declares intentional non-zero exits, such as `[1]` for an `rg`/`grep` no-match or a `git diff --exit-code` probe. Only the final shell exit code is classified, taken from the tool's structured result, never from output text. Timeouts, aborts, and undeclared codes stay ordinary failures.
|
|
54
|
+
- `expectedExitCodes` (distinct integers 1–255; a `0` is ignored, since exit 0 is already success) declares intentional non-zero exits, such as `[1]` for an `rg`/`grep` no-match or a `git diff --exit-code` probe. Only the final shell exit code is classified, taken from the tool's structured result, never from output text. Timeouts, aborts, and undeclared codes stay ordinary failures.
|
|
53
55
|
- `failure_disposition({ disposition, targets, reason, evidence? })` records an explicit classification of the child's own incidents:
|
|
54
56
|
- `recovered`: the same operation later passed. Evidence must be a successful attempt of that operation that started after the failure.
|
|
55
57
|
- `superseded`: a different verification or remediation established the outcome, such as resolving a merge conflict and continuing. Evidence must be a successful attempt that started after the failure.
|
package/failures.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { logPathFor, runDir, taskRuntimeTrust } from "./registry.ts";
|
|
4
|
-
import { activeFailures, disposeIncidents, failureIdentity, formatFailureSummary, formatTerminalFailureFacts, markFailureAttentionDelivered,
|
|
4
|
+
import { activeFailures, actionableFailures, disposeIncidents, failureIdentity, findIncident, formatFailureSummary, formatTerminalFailureFacts, markFailureAttentionDelivered,
|
|
5
5
|
observeFailures, pendingFailureAttention, readFailureState, type FailureState } from "./shared-failure-observations.ts";
|
|
6
6
|
import { evidenceText, foldToolEnd, foldToolStart, newIncidentModel, toolOperation, type IncidentModel, type IncidentSink } from "./incident-model.ts";
|
|
7
7
|
|
|
8
8
|
export { formatFailureSummary, pendingFailureAttention, markFailureAttentionDelivered, toolOperation };
|
|
9
9
|
export const failurePath = (id: string) => join(runDir(id), "failures.jsonl");
|
|
10
10
|
export const readRunFailures = (id: string): FailureState => readFailureState(failurePath(id));
|
|
11
|
-
interface Scan {
|
|
11
|
+
interface Scan {
|
|
12
|
+
offset: number; head: string; identity: string; model: IncidentModel; retry?: number;
|
|
13
|
+
/**
|
|
14
|
+
* A "run metadata could not be read" gap this trusted rescan inherited, with the tool incidents
|
|
15
|
+
* that were unresolved when it began (the earlier exact-rule scan's). Re-checked after every scan.
|
|
16
|
+
*/
|
|
17
|
+
gap?: { id: string; before: Set<string> };
|
|
18
|
+
}
|
|
12
19
|
const scans = new Map<string, Scan>();
|
|
13
20
|
/** When each run's trust first could not be read, for the bounded wait (#325). */
|
|
14
21
|
const trustUnknownSince = new Map<string, number>();
|
|
@@ -74,6 +81,73 @@ function fold(id: string, scan: Scan, row: any, offset: number, cwd: string): vo
|
|
|
74
81
|
++model.sequence;
|
|
75
82
|
}
|
|
76
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Close an inherited metadata gap unless an exact-rule leftover still needs it. A leftover counts only
|
|
86
|
+
* when its attempt declared intent (operationId, attemptId, or expectedExitCodes): its event id is
|
|
87
|
+
* already consumed under the exact rule, so this trusted scan cannot re-key it, and a declared retry
|
|
88
|
+
* or expected exit cannot resolve it. A plain failure reads the same under either rule and never
|
|
89
|
+
* holds the gap. Once the leftover is resolved (an exact retry or a disposition), the gap closes.
|
|
90
|
+
*/
|
|
91
|
+
function settleMetadataGap(id: string, scan: Scan): void {
|
|
92
|
+
const gap = scan.gap!;
|
|
93
|
+
const state = readRunFailures(id);
|
|
94
|
+
const current = state.observations[failureIdentity("run-metadata")];
|
|
95
|
+
if (!current || current.id !== gap.id || current.status !== "unresolved") { scan.gap = undefined; return; }
|
|
96
|
+
const intentBearing = new Set<string>();
|
|
97
|
+
for (const attempt of scan.model.finished.values()) {
|
|
98
|
+
if (attempt.incident && gap.before.has(attempt.incident) && Object.keys(attempt.intent).length) intentBearing.add(attempt.incident);
|
|
99
|
+
}
|
|
100
|
+
const held = [...intentBearing].some((incident) => findIncident(state, incident)?.status === "unresolved");
|
|
101
|
+
if (held) return;
|
|
102
|
+
observeFailures(failurePath(id), [{ id: failureIdentity("run-metadata-readable", gap.id), operation: "run-metadata", kind: "recovered", incidents: [gap.id] }]);
|
|
103
|
+
scan.gap = undefined;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Scan-period markers (#332). While a "run metadata could not be read" gap is open, each scan that
|
|
107
|
+
* starts under a different rule from the last marked one journals a no-op marker (a `delivered`
|
|
108
|
+
* event naming no incident) whose id records the rule: `run-metadata-scan:<rule>:<journal length>`.
|
|
109
|
+
* The ids are read back by prefix, so they are plain strings, not `failureIdentity` hashes.
|
|
110
|
+
*/
|
|
111
|
+
const SCAN_MARKER_PREFIX = "run-metadata-scan:";
|
|
112
|
+
type ScanRule = "exact" | "trusted";
|
|
113
|
+
function scanRuleAt(marker: string): ScanRule | undefined {
|
|
114
|
+
if (!marker.startsWith(SCAN_MARKER_PREFIX)) return undefined;
|
|
115
|
+
const rule = marker.slice(SCAN_MARKER_PREFIX.length).split(":")[0];
|
|
116
|
+
return rule === "exact" || rule === "trusted" ? rule : undefined;
|
|
117
|
+
}
|
|
118
|
+
/** Journal that a scan under `rule` begins, unless the last marked period already used it. */
|
|
119
|
+
function markScanPeriod(path: string, state: FailureState, rule: ScanRule): FailureState {
|
|
120
|
+
let last: ScanRule | undefined;
|
|
121
|
+
for (let i = state.seen.length - 1; i >= 0 && !last; i -= 1) last = scanRuleAt(state.seen[i]!);
|
|
122
|
+
if (last === rule) return state;
|
|
123
|
+
return observeFailures(path, [{ id: `${SCAN_MARKER_PREFIX}${rule}:${state.seen.length}`, operation: "run-metadata", kind: "delivered", incidents: [] }]);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The unresolved tool incidents first journaled by an exact-rule scan, which a trusted scan cannot
|
|
127
|
+
* re-key. Each incident belongs to the period of the last marker before it, so a failure from a
|
|
128
|
+
* trusted period is never mistaken for a leftover, however often the scan is rebuilt (a parent
|
|
129
|
+
* restart, `/reload`, or cache eviction), and a later exact-rule period's failures always are. An
|
|
130
|
+
* incident before every marker is a leftover only when the journal has no exact-rule marker at all
|
|
131
|
+
* (the first trusted scan of a gap, or a journal from before the markers existed).
|
|
132
|
+
*/
|
|
133
|
+
function exactRuleLeftovers(path: string, prior: FailureState): Set<string> {
|
|
134
|
+
const state = markScanPeriod(path, prior, "trusted");
|
|
135
|
+
const period = new Map<string, ScanRule | undefined>();
|
|
136
|
+
let current: ScanRule | undefined;
|
|
137
|
+
let sawExact = false;
|
|
138
|
+
for (const id of state.seen) {
|
|
139
|
+
const rule = scanRuleAt(id);
|
|
140
|
+
if (rule) { current = rule; sawExact ||= rule === "exact"; continue; }
|
|
141
|
+
period.set(id, current);
|
|
142
|
+
}
|
|
143
|
+
const leftover = (id: string) => {
|
|
144
|
+
if (!period.has(id)) return false;
|
|
145
|
+
const rule = period.get(id);
|
|
146
|
+
return rule === "exact" || (rule === undefined && !sawExact);
|
|
147
|
+
};
|
|
148
|
+
return new Set(Object.values(state.observations)
|
|
149
|
+
.filter((x) => x.category === "tool" && x.status === "unresolved" && leftover(x.id)).map((x) => x.id));
|
|
150
|
+
}
|
|
77
151
|
/** Scan complete source records, independent of the finite progress/transcript tail. */
|
|
78
152
|
export function collectRunFailures(id: string, cwd: string, terminal = false): FailureState {
|
|
79
153
|
const path = failurePath(id);
|
|
@@ -123,13 +197,17 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
|
|
|
123
197
|
summary: "Run metadata could not be read; tool failures are scanned under the exact-retry rule" }]);
|
|
124
198
|
}
|
|
125
199
|
if (!waited) return prior;
|
|
200
|
+
markScanPeriod(path, readRunFailures(id), "exact");
|
|
126
201
|
scan = { offset: 0, head, identity, model: newIncidentModel(false) };
|
|
127
202
|
} else {
|
|
128
203
|
trustUnknownSince.delete(id);
|
|
129
204
|
scan = { offset: 0, head, identity, model: newIncidentModel(trust === "trusted") };
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
205
|
+
// The gap is settled after this scan has folded the log (settleMetadataGap), and again after
|
|
206
|
+
// every later scan, once no leftover of the exact-rule scan that trust would have read
|
|
207
|
+
// differently is still unresolved (#332).
|
|
208
|
+
const prior = readRunFailures(id);
|
|
209
|
+
const deferred = prior.observations[failureIdentity("run-metadata")];
|
|
210
|
+
if (deferred && deferred.status === "unresolved") scan.gap = { id: deferred.id, before: exactRuleLeftovers(path, prior) };
|
|
133
211
|
}
|
|
134
212
|
}
|
|
135
213
|
scan.head = head;
|
|
@@ -178,6 +256,7 @@ export function collectRunFailures(id: string, cwd: string, terminal = false): F
|
|
|
178
256
|
position += count;
|
|
179
257
|
}
|
|
180
258
|
scan.offset = start;
|
|
259
|
+
if (scan.gap) settleMetadataGap(id, scan);
|
|
181
260
|
scans.delete(id);
|
|
182
261
|
scans.set(id, scan);
|
|
183
262
|
while (scans.size > 64) scans.delete(scans.keys().next().value!);
|
|
@@ -200,7 +279,16 @@ export function runFailureFacts(state: FailureState, terminal: boolean): string
|
|
|
200
279
|
return formatTerminalFailureFacts(state, activeFailures(state).map((x) => x.id));
|
|
201
280
|
}
|
|
202
281
|
export function failureSummary(id: string, cwd: string, terminal = false): string {
|
|
203
|
-
return
|
|
282
|
+
return failureView(id, cwd, terminal).text;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The failure facts plus whether anything needs action. A navigator row leads with failure text only
|
|
286
|
+
* when something does; a quiet "No failures need action · … (history)" line never displaces the
|
|
287
|
+
* row's model, tool, and spend columns (#332).
|
|
288
|
+
*/
|
|
289
|
+
export function failureView(id: string, cwd: string, terminal = false): { text: string; actionable: boolean } {
|
|
290
|
+
const state = collectRunFailures(id, cwd, terminal);
|
|
291
|
+
return { text: runFailureFacts(state, terminal), actionable: actionableFailures(state).length > 0 };
|
|
204
292
|
}
|
|
205
293
|
export function prependFailureSummary(body: string, summary: string): string {
|
|
206
294
|
if (!summary) return body;
|
package/incident-model.ts
CHANGED
|
@@ -8,12 +8,10 @@
|
|
|
8
8
|
* `operationId` or the exact tool/arguments/cwd hash; expected exits require a code
|
|
9
9
|
* declared on the attempt before it ran and a structured exit code from the tool.
|
|
10
10
|
*/
|
|
11
|
-
import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS,
|
|
11
|
+
import { failureIdentity, findIncident, validateDisposition, INCIDENT_DISPOSITIONS,
|
|
12
12
|
REJECTED_INTENT_CATEGORY, readCommandIntent, withoutAbsentIntent, type CommandIntent, type FailureEvent, type FailureState, type IncidentDisposition } from "./shared-failure-observations.ts";
|
|
13
13
|
|
|
14
14
|
export const DISPOSITION_TOOL = "failure_disposition";
|
|
15
|
-
// The intent validator is shared with background tasks through the vendored failure-observations module (#325).
|
|
16
|
-
export { INTENT_ID_PATTERN, MAX_EXPECTED_EXIT_CODES, readCommandIntent, withoutAbsentIntent, type CommandIntent };
|
|
17
15
|
|
|
18
16
|
function stable(value: unknown): unknown {
|
|
19
17
|
if (Array.isArray(value)) return value.map(stable);
|
|
@@ -202,15 +200,19 @@ export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink:
|
|
|
202
200
|
const seq = ++model.sequence;
|
|
203
201
|
if (!model.structuredIntent) {
|
|
204
202
|
// Unconfined: intent-looking fields are ordinary arguments; exact identity only.
|
|
205
|
-
|
|
203
|
+
// An explicit-null intent-named field is still normalized away, as on the confined path, so
|
|
204
|
+
// `{command, expectedExitCodes: null}` and `{command}` are one command (#332).
|
|
205
|
+
model.open.set(toolCallId, { toolCallId, toolName, operation: toolOperation(toolName, withoutAbsentIntent(row.args), cwd), intent: {}, startSequence: seq });
|
|
206
206
|
return;
|
|
207
207
|
}
|
|
208
208
|
const parsed = toolName === "bash" ? readCommandIntent(row.args) : { intent: {} as CommandIntent };
|
|
209
209
|
const intent = parsed.intent;
|
|
210
|
-
// Mirrors the child's check: any earlier bash start that named this attemptId, valid or not.
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
|
|
210
|
+
// Mirrors the child's check: any earlier bash start that named this attemptId, valid or not. The name
|
|
211
|
+
// is read after the same normalization the child applies, so `attemptId: ""` or `null` names nothing (#332).
|
|
212
|
+
const namedArgs = toolName === "bash" ? withoutAbsentIntent(row.args) : undefined;
|
|
213
|
+
const namedAttemptId = typeof namedArgs?.attemptId === "string" ? namedArgs.attemptId as string : undefined;
|
|
214
|
+
const reused = namedAttemptId !== undefined && model.namedAttempts.has(namedAttemptId);
|
|
215
|
+
if (namedAttemptId !== undefined) model.namedAttempts.add(namedAttemptId);
|
|
214
216
|
const predictedRejection = parsed.error ?? (reused ? `attemptId ${intent.attemptId} was already used` : undefined);
|
|
215
217
|
// A predicted refusal falls back to exact identity: if the child ran the command after all, it is
|
|
216
218
|
// an ordinary failure of exactly that command, never a failure of the operation it named.
|
|
@@ -238,8 +240,13 @@ export function foldToolStart(model: IncidentModel, row: any, cwd: string, sink:
|
|
|
238
240
|
|
|
239
241
|
/** The confined intent bash's own pre-run refusal (child-incidents.ts), matched as the whole result text. */
|
|
240
242
|
const INTENT_REFUSAL = /^Invalid command intent: ([\s\S]+)\. The command was not run\.$/;
|
|
241
|
-
/**
|
|
242
|
-
|
|
243
|
+
/**
|
|
244
|
+
* Pi's pre-execution schema rejection of the bash call (pi-ai `validateToolArguments`), matched as its
|
|
245
|
+
* whole shape: the header, one or more ` - path: message` lines, and the received arguments as
|
|
246
|
+
* pretty-printed JSON ending the text, so a command that ran (and ends "Command exited with code N")
|
|
247
|
+
* can never match. Nothing ran.
|
|
248
|
+
*/
|
|
249
|
+
const SCHEMA_REFUSAL = /^Validation failed for tool "bash":\n(?: - [^\n]*\n)+\nReceived arguments:\n(?:\{\}|\{(?:\n {2,}[^\n]*)*\n\})$/;
|
|
243
250
|
|
|
244
251
|
/**
|
|
245
252
|
* Why the child refused this bash call before running it, read from the end row the child
|
|
@@ -266,7 +273,7 @@ export function foldToolEnd(model: IncidentModel, row: any, cwd: string, evidenc
|
|
|
266
273
|
const attempt = model.open.get(toolCallId);
|
|
267
274
|
if (attempt) model.open.delete(toolCallId);
|
|
268
275
|
const intent = attempt?.intent ?? {};
|
|
269
|
-
const operation = attempt?.operation ?? toolOperation(toolName, row.args, cwd);
|
|
276
|
+
const operation = attempt?.operation ?? toolOperation(toolName, withoutAbsentIntent(row.args), cwd);
|
|
270
277
|
const details = row.result?.details;
|
|
271
278
|
const declaredExit = row.isError === false && details?.expectedExit === true && typeof details.exitCode === "number" &&
|
|
272
279
|
(intent.expectedExitCodes ?? []).includes(details.exitCode) ? details.exitCode as number : undefined;
|
package/index.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { execSync } from "node:child_process";
|
|
15
|
-
import { writeFileSync, mkdirSync, renameSync,
|
|
15
|
+
import { writeFileSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
16
16
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import * as PiCodingAgent from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import * as PiTui from "@earendil-works/pi-tui";
|
|
@@ -57,6 +57,7 @@ import { resolveExtensions, extensionArgs } from "./extensions.ts";
|
|
|
57
57
|
import { prepareTaskRuntime } from "./task-policy.ts";
|
|
58
58
|
import { canonicalizePath, takeRecoverySnapshot } from "./shared-sandbox-core.ts";
|
|
59
59
|
import { TASK_BUILTINS } from "./shared-task-sandbox.ts";
|
|
60
|
+
import { APPLY_PATCH, describeTaskTools, planTaskTools } from "./subagent-tools.ts";
|
|
60
61
|
import { observeSandboxPermissions, resolveSubagentPermissions } from "./permission-policy.ts";
|
|
61
62
|
import { resolveSubagentWorkspace } from "./git-workspace.ts";
|
|
62
63
|
import { join } from "node:path";
|
|
@@ -66,7 +67,7 @@ import {
|
|
|
66
67
|
sessionsDir,
|
|
67
68
|
runDir,
|
|
68
69
|
recordTaskRuntimeProvenance,
|
|
69
|
-
|
|
70
|
+
discardFailedLaunch,
|
|
70
71
|
logPathFor,
|
|
71
72
|
promptPathFor,
|
|
72
73
|
nextRunId,
|
|
@@ -115,7 +116,7 @@ import {
|
|
|
115
116
|
import { buildHealthCallbackDelivery } from "./completion.ts";
|
|
116
117
|
import { cancelCallbackBatch, getCallbackBatcher } from "./shared-callback-batcher.ts";
|
|
117
118
|
import { completionCallbackFields, failureAttentionFields, healthCallbackFields } from "./callback-fields.ts";
|
|
118
|
-
import { collectRunFailures, failurePath,
|
|
119
|
+
import { collectRunFailures, failurePath, failureView, markFailureAttentionDelivered, pendingFailureAttention, prependFailureSummary } from "./failures.ts";
|
|
119
120
|
import { failureAttentionHandled, observeFailures } from "./shared-failure-observations.ts";
|
|
120
121
|
import {
|
|
121
122
|
text,
|
|
@@ -1132,8 +1133,9 @@ function subagentWorkRows(now: number): BackgroundWorkRow[] {
|
|
|
1132
1133
|
if (row.model) bits.push(row.effort ? `${row.model} ${row.effort}` : row.model);
|
|
1133
1134
|
if (row.tool) bits.push(row.tool);
|
|
1134
1135
|
if (row.spend) bits.push(row.spend);
|
|
1135
|
-
const failure =
|
|
1136
|
-
|
|
1136
|
+
const failure = failureView(row.id, metaById.get(row.id)?.cwd ?? "", row.status !== "running" && row.status !== "orphaned");
|
|
1137
|
+
// Only a failure that needs action replaces the row's columns; history stays in the detail view.
|
|
1138
|
+
const firstFailure = failure.actionable ? failure.text.split("\n")[0] || "" : "";
|
|
1137
1139
|
return {
|
|
1138
1140
|
providerId: "subagents",
|
|
1139
1141
|
id: row.id,
|
|
@@ -1197,7 +1199,8 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
|
|
|
1197
1199
|
if (!detail) return null;
|
|
1198
1200
|
void options;
|
|
1199
1201
|
const transcript = readRunTranscript(id);
|
|
1200
|
-
const
|
|
1202
|
+
const view = failureView(id, readMeta(id)?.cwd ?? "", detail.status !== "running" && detail.status !== "orphaned");
|
|
1203
|
+
const failure = view.text;
|
|
1201
1204
|
const metadata = [
|
|
1202
1205
|
...(failure ? [{ label: "failure", value: failure.split("\n")[0]! }] : []),
|
|
1203
1206
|
{ label: "provider", value: "Subagents" },
|
|
@@ -1216,7 +1219,7 @@ function subagentWorkDetail(id: string, now: number, options?: { logTailLines?:
|
|
|
1216
1219
|
title: detail.name || detail.id,
|
|
1217
1220
|
status: detail.status,
|
|
1218
1221
|
statusTone: statusTone(detail.status),
|
|
1219
|
-
subtitle: failure.split("\n")[0] || (detail.currentTool ? `current tool ${detail.currentTool}` : undefined),
|
|
1222
|
+
subtitle: (view.actionable && failure.split("\n")[0]) || (detail.currentTool ? `current tool ${detail.currentTool}` : undefined),
|
|
1220
1223
|
metadata,
|
|
1221
1224
|
evidence: { label: "transcript", text: prependFailureSummary(detail.output || "(no transcript yet)", failure) },
|
|
1222
1225
|
transcript: transcript.entries,
|
|
@@ -1587,13 +1590,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1587
1590
|
|
|
1588
1591
|
const id = nextRunId();
|
|
1589
1592
|
const childSessionDir = sandboxEnabled ? join(sessionsDir(), id) : sessionsDir();
|
|
1593
|
+
// The disposable clone this launch owns (removed if the spawn fails); a caller's sandbox_dir is not ours.
|
|
1594
|
+
const ownedCloneWorkspace = !p.sandbox_dir && sandboxEnabled && p.git_clone_workspace ? taskWorkspaceDir(id) : undefined;
|
|
1590
1595
|
mkdirSync(childSessionDir, { recursive: true });
|
|
1591
1596
|
mkdirSync(runDir(id), { recursive: true });
|
|
1592
1597
|
|
|
1593
1598
|
const workspace = resolveSubagentWorkspace({
|
|
1594
1599
|
ctxCwd: ctx.cwd,
|
|
1595
1600
|
cwd: p.cwd,
|
|
1596
|
-
sandboxDir: p.sandbox_dir ??
|
|
1601
|
+
sandboxDir: p.sandbox_dir ?? ownedCloneWorkspace,
|
|
1597
1602
|
gitCloneWorkspace: p.git_clone_workspace,
|
|
1598
1603
|
runId: id,
|
|
1599
1604
|
runDirPath: runDir(id),
|
|
@@ -1616,14 +1621,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
1616
1621
|
.filter(Boolean).join(",");
|
|
1617
1622
|
}
|
|
1618
1623
|
|
|
1624
|
+
// A confined child gets extension tools only through /sandbox Subagents · Tools (ADR 0009):
|
|
1625
|
+
// the guarded apply_patch adapter, and trusted tools admitted by name and package.
|
|
1626
|
+
const requestedExcludes = new Set((p.exclude_tools ?? "").split(",").map((t) => t.trim()).filter(Boolean));
|
|
1627
|
+
if (sandboxEnabled && !clean) {
|
|
1628
|
+
const names = allow.split(",").filter(Boolean);
|
|
1629
|
+
// Models reach for apply_patch whenever they may edit; ticked trusted tools join the default set.
|
|
1630
|
+
if (permissionPlan.tools.applyPatch && names.some((name) => name === "edit" || name === "write")) names.push(APPLY_PATCH);
|
|
1631
|
+
if (p.tools === undefined) names.push(...permissionPlan.tools.trusted.map((entry) => entry.name));
|
|
1632
|
+
allow = [...new Set(names)].join(",");
|
|
1633
|
+
}
|
|
1634
|
+
const taskTools = sandboxEnabled ? planTaskTools({
|
|
1635
|
+
requested: allow.split(",").filter((name) => name && !requestedExcludes.has(name)),
|
|
1636
|
+
settings: permissionPlan.tools, network: permissionPlan.permissions?.network ?? true,
|
|
1637
|
+
builtins: TASK_BUILTINS, registered: pi.getAllTools?.() ?? [],
|
|
1638
|
+
toolExtensions: cfg.toolExtensions, resolvePath: resolveExtensionPath,
|
|
1639
|
+
}) : undefined;
|
|
1619
1640
|
const resolution = resolveExtensions({
|
|
1620
1641
|
tools: sandboxEnabled ? allow.split(",").filter((name) => (TASK_BUILTINS as readonly string[]).includes(name)).join(",") : allow,
|
|
1621
1642
|
model, clean, allowNested: sandboxEnabled ? false : p.allow_nested, config: cfg,
|
|
1622
1643
|
});
|
|
1623
1644
|
const { args: resolvedExtArgs, missing } = extensionArgs(resolution, resolveExtensionPath);
|
|
1645
|
+
const trustedExtArgs = [...new Set(taskTools?.trusted.map((tool) => tool.loadPath) ?? [])]
|
|
1646
|
+
.filter((path) => !resolvedExtArgs.includes(path)).flatMap((path) => ["--extension", path]);
|
|
1624
1647
|
// The harness's own child control extension (no tools, no command I/O) rides along in every
|
|
1625
1648
|
// mode, including clean: it is how a soft-deadline steer reaches the child session.
|
|
1626
|
-
const extArgs = [...resolvedExtArgs, "--extension", childSteerExtensionPath()]
|
|
1649
|
+
const extArgs = [...resolvedExtArgs, ...trustedExtArgs, "--extension", childSteerExtensionPath()]
|
|
1627
1650
|
.map((value, index, all) => sandboxEnabled && all[index - 1] === "--extension" ? canonicalizePath(value) : value);
|
|
1628
1651
|
if (sandboxEnabled && resolution.mode === "inherit") {
|
|
1629
1652
|
throw new Error("Task confinement requires explicit extensions; inheritExtensions is unsupported while sandboxing is enabled.");
|
|
@@ -1656,13 +1679,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1656
1679
|
|
|
1657
1680
|
const piBin = resolvePiBinary();
|
|
1658
1681
|
const selectedTools = allow.split(",").filter((name) => name && !excludes.has(name));
|
|
1659
|
-
const
|
|
1682
|
+
const unavailable = taskTools?.refused.filter((tool) => selectedTools.includes(tool.name)) ?? [];
|
|
1683
|
+
const unavailableTools = unavailable.map((tool) => tool.name);
|
|
1660
1684
|
if (sandboxEnabled && selectedTools.length && unavailableTools.length === selectedTools.length) {
|
|
1661
|
-
throw new Error(`No requested tool has a verified task sandbox adapter: ${
|
|
1685
|
+
throw new Error(`No requested tool has a verified task sandbox adapter: ${unavailable.map((tool) => `${tool.name} (${tool.reason})`).join(", ")}.`);
|
|
1662
1686
|
}
|
|
1663
1687
|
const taskRuntime = sandboxEnabled && requestedSandboxDir ? prepareTaskRuntime({
|
|
1664
1688
|
root: requestedSandboxDir, controlDir: join(runDir(id), "control"), piBin,
|
|
1665
1689
|
tools: selectedTools, permissions: permissionPlan.permissions,
|
|
1690
|
+
applyPatch: taskTools?.applyPatch === true,
|
|
1691
|
+
extensionTools: taskTools?.trusted.map(({ loadPath: _loadPath, ...tool }) => tool) ?? [],
|
|
1666
1692
|
extensionPaths: extArgs.flatMap((arg, index) => arg === "--extension" && extArgs[index + 1] ? [extArgs[index + 1]!] : []),
|
|
1667
1693
|
runtimeRoots: [baseDir()],
|
|
1668
1694
|
}) : undefined;
|
|
@@ -1686,10 +1712,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
1686
1712
|
spawned = spawnDetached({ file: cmd.file, fileArgs: cmd.fileArgs, cwd, logPath: logPathFor(id),
|
|
1687
1713
|
env: { [STEER_FILE_ENV]: steerPathFor(id) } });
|
|
1688
1714
|
} catch (error) {
|
|
1689
|
-
// Nothing references the run yet (no metadata): drop its directory (prompt, control/),
|
|
1690
|
-
//
|
|
1691
|
-
|
|
1692
|
-
|
|
1715
|
+
// Nothing references the run yet (no metadata): drop its directory (prompt, control/), its
|
|
1716
|
+
// provenance record, its session directory, its clone workspace, and the task scratch, so a
|
|
1717
|
+
// failed launch leaves nothing behind (#325, #332).
|
|
1718
|
+
discardFailedLaunch(id, { sessionDir: childSessionDir, workspaceDir: ownedCloneWorkspace, scratch: taskRuntime?.policy.scratch });
|
|
1693
1719
|
throw error;
|
|
1694
1720
|
}
|
|
1695
1721
|
// Record process identity (pgid, start-time token) so health
|
|
@@ -1726,13 +1752,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
1726
1752
|
// Footer hint: a visible run now exists, so `← background work · N` shows.
|
|
1727
1753
|
updateNavigatorFooter(ctx);
|
|
1728
1754
|
|
|
1755
|
+
const toolSummary = taskTools ? describeTaskTools(taskTools) : undefined;
|
|
1729
1756
|
const runtime = resolution.mode === "inherit"
|
|
1730
1757
|
? `Runtime: ALL installed extensions (inheritExtensions) — mid-turn drain risk\n`
|
|
1731
|
-
:
|
|
1758
|
+
: toolSummary
|
|
1759
|
+
? `Runtime: isolated · ${toolSummary}${resolution.specs.length ? ` · extensions ${resolution.specs.join(", ")}` : ""}\n`
|
|
1760
|
+
: resolution.specs.length
|
|
1732
1761
|
? `Runtime: isolated · extensions ${resolution.specs.join(", ")}\n`
|
|
1733
1762
|
: `Runtime: isolated · built-in tools only\n`;
|
|
1734
|
-
const warn = (
|
|
1735
|
-
(resolution.unmapped.length
|
|
1763
|
+
const warn = (unavailable.length ? `Task sandbox: ${unavailable.map((tool) => `${tool.name} disabled (${tool.reason})`).join("; ")}.\n` : "") +
|
|
1764
|
+
(!sandboxEnabled && resolution.unmapped.length
|
|
1736
1765
|
? `NOTE: no extension mapped for ${resolution.unmapped.join(", ")} — ` +
|
|
1737
1766
|
`${resolution.unmapped.length > 1 ? "these tools" : "this tool"} will NOT exist in the child. ` +
|
|
1738
1767
|
`Add a toolExtensions entry in config.json.\n`
|
package/output-payload.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
import {
|
|
49
49
|
actionableFailures,
|
|
50
50
|
failureJournalFingerprint,
|
|
51
|
+
fileChangeIdentity,
|
|
51
52
|
failureRevision,
|
|
52
53
|
formatIncidentSummary,
|
|
53
54
|
formatTerminalIncidentSummary,
|
|
@@ -74,7 +75,6 @@ export interface PayloadRequest {
|
|
|
74
75
|
mode?: unknown;
|
|
75
76
|
all?: unknown;
|
|
76
77
|
lines?: unknown;
|
|
77
|
-
tail_lines?: unknown;
|
|
78
78
|
limit?: unknown;
|
|
79
79
|
/** Explicit opt-in facts: `cost` (token/cost spend) and/or `tools` (tool-call count). */
|
|
80
80
|
include?: unknown;
|
|
@@ -409,12 +409,12 @@ function answerReserve(maxBytes: number, cursor: string | undefined): number {
|
|
|
409
409
|
}
|
|
410
410
|
|
|
411
411
|
function outputLineCap(request: PayloadRequest): number {
|
|
412
|
-
return positiveInt(request.lines) ??
|
|
412
|
+
return positiveInt(request.lines) ?? OUTPUT_PAGE_DEFAULTS.logLines;
|
|
413
413
|
}
|
|
414
414
|
|
|
415
415
|
/** Answer-page line cap for subagent_result: only an explicit `lines` request caps it. */
|
|
416
416
|
function answerLineCap(request: PayloadRequest): number | undefined {
|
|
417
|
-
return positiveInt(request.lines)
|
|
417
|
+
return positiveInt(request.lines);
|
|
418
418
|
}
|
|
419
419
|
|
|
420
420
|
/**
|
|
@@ -778,16 +778,7 @@ const LIST_INCIDENT_CACHE_MAX = 4096;
|
|
|
778
778
|
let listIncidentComputations = 0;
|
|
779
779
|
|
|
780
780
|
function listIncidentKey(id: string, terminal: boolean): string {
|
|
781
|
-
return JSON.stringify([terminal,
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
function logIdentityKey(path: string): string {
|
|
785
|
-
try {
|
|
786
|
-
const stats = statSync(path, { bigint: true });
|
|
787
|
-
return `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}`;
|
|
788
|
-
} catch (error) {
|
|
789
|
-
return `unreadable:${(error as NodeJS.ErrnoException).code ?? "error"}`;
|
|
790
|
-
}
|
|
781
|
+
return JSON.stringify([terminal, fileChangeIdentity(logPathFor(id)), failureJournalFingerprint(failurePath(id))]);
|
|
791
782
|
}
|
|
792
783
|
|
|
793
784
|
export function listIncidentCount(id: string, cwd: string, terminal: boolean): ListIncidentCount {
|
package/package.json
CHANGED
package/permission-policy.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** Optional sandbox-extension policy, mirrored per Pi event bus at launch time. */
|
|
2
|
+
import { defaultSubagentTools, parseSubagentTools, type SubagentToolSettings } from "./shared-task-tools.ts";
|
|
2
3
|
export const SANDBOX_POLICY_CHANNEL = "pi-better-sandbox:policy";
|
|
3
4
|
export const SANDBOX_POLICY_REQUEST_CHANNEL = "pi-better-sandbox:policy-request";
|
|
4
5
|
|
|
@@ -15,6 +16,7 @@ export type PermissionProfile = Readonly<{
|
|
|
15
16
|
export type PermissionSnapshot = Readonly<{
|
|
16
17
|
permissions?: PermissionProfile;
|
|
17
18
|
subagentPermissions?: PermissionProfile;
|
|
19
|
+
subagentTools?: SubagentToolSettings;
|
|
18
20
|
}>;
|
|
19
21
|
|
|
20
22
|
type EventBus = {
|
|
@@ -52,6 +54,7 @@ function readPolicy(data: unknown): PermissionSnapshot | undefined {
|
|
|
52
54
|
return Object.freeze({
|
|
53
55
|
...(p.permissions === undefined ? {} : { permissions: profile(p.permissions) }),
|
|
54
56
|
...(p.subagentPermissions === undefined ? {} : { subagentPermissions: profile(p.subagentPermissions) }),
|
|
57
|
+
...(p.subagentTools === undefined ? {} : { subagentTools: parseSubagentTools(p.subagentTools) }),
|
|
55
58
|
});
|
|
56
59
|
}
|
|
57
60
|
|
|
@@ -87,20 +90,23 @@ export function resolveSubagentPermissions(pi: unknown, requestedSandbox: boolea
|
|
|
87
90
|
sandboxEnabled: boolean;
|
|
88
91
|
enforced: boolean;
|
|
89
92
|
permissions?: Omit<PermissionProfile, "enabled">;
|
|
93
|
+
/** Which extension tools a confined child may use; defaults when no settings are published. */
|
|
94
|
+
tools: SubagentToolSettings;
|
|
90
95
|
} {
|
|
91
96
|
const snapshot = currentSandboxPermissions(pi);
|
|
97
|
+
const tools = snapshot?.subagentTools ?? defaultSubagentTools();
|
|
92
98
|
if (snapshot?.permissions?.enabled && !snapshot.permissions.commands) {
|
|
93
99
|
throw new Error("Main sandbox profile disables commands. Enable commands in the sandbox UI before launching a subagent.");
|
|
94
100
|
}
|
|
95
101
|
const child = snapshot?.subagentPermissions;
|
|
96
|
-
if (!child) return { sandboxEnabled: requestedSandbox !== false, enforced: false };
|
|
102
|
+
if (!child) return { sandboxEnabled: requestedSandbox !== false, enforced: false, tools };
|
|
97
103
|
if (child.enabled && requestedSandbox === false) {
|
|
98
104
|
throw new Error("Subagent sandbox is enforced by the human-enabled profile; sandbox:false cannot bypass it. Change Subagents permissions in the sandbox UI.");
|
|
99
105
|
}
|
|
100
106
|
const sandboxEnabled = child.enabled || requestedSandbox === true;
|
|
101
|
-
if (!sandboxEnabled) return { sandboxEnabled: false, enforced: false };
|
|
107
|
+
if (!sandboxEnabled) return { sandboxEnabled: false, enforced: false, tools };
|
|
102
108
|
// Starting Pi is runtime work. These capabilities constrain task tools;
|
|
103
109
|
// they do not disable provider transport or the fixed file-operation worker.
|
|
104
110
|
const { enabled: _enabled, ...permissions } = child;
|
|
105
|
-
return { sandboxEnabled: true, enforced: true, permissions };
|
|
111
|
+
return { sandboxEnabled: true, enforced: true, permissions, tools };
|
|
106
112
|
}
|
package/registry.ts
CHANGED
|
@@ -235,16 +235,33 @@ export function removeRunDirectory(id: string): void {
|
|
|
235
235
|
rmSync(runDir(id), { recursive: true, force: true });
|
|
236
236
|
removeTaskRuntimeProvenance(id);
|
|
237
237
|
}
|
|
238
|
+
/**
|
|
239
|
+
* Undo everything a launch prepared before its child failed to spawn (#325, #332). Nothing references
|
|
240
|
+
* the run yet (no metadata), so its run directory and provenance go, plus the per-run session
|
|
241
|
+
* directory and disposable clone workspace of a sandboxed launch and the task scratch. Only the
|
|
242
|
+
* run's own paths are removed: a shared sessions directory or a caller-chosen sandbox_dir is kept.
|
|
243
|
+
*/
|
|
244
|
+
export function discardFailedLaunch(id: string, prepared: { sessionDir?: string; workspaceDir?: string; scratch?: string } = {}): void {
|
|
245
|
+
try { removeRunDirectory(id); } catch { /* best effort */ }
|
|
246
|
+
const remove = (path: string) => { try { rmSync(path, { recursive: true, force: true }); } catch { /* best effort */ } };
|
|
247
|
+
if (prepared.sessionDir && prepared.sessionDir === join(sessionsDir(), id)) remove(prepared.sessionDir);
|
|
248
|
+
if (prepared.workspaceDir && /^sa_[a-z0-9]+_[a-z0-9]+$/i.test(id) && prepared.workspaceDir === taskWorkspaceDir(id)) remove(prepared.workspaceDir);
|
|
249
|
+
if (prepared.scratch) remove(prepared.scratch);
|
|
250
|
+
}
|
|
238
251
|
/** Best-effort removal of a run's provenance record (e.g. after a failed launch). */
|
|
239
252
|
export function removeTaskRuntimeProvenance(id: string): void {
|
|
240
253
|
try { rmSync(taskRuntimeProvenancePath(id), { force: true }); } catch { /* invalid id or already gone */ }
|
|
241
254
|
}
|
|
242
|
-
/**
|
|
255
|
+
/**
|
|
256
|
+
* Provenance records whose run directory no longer exists (e.g. removed by an older version). Only a
|
|
257
|
+
* definite ENOENT counts as gone: any other stat error (permissions, I/O) keeps the record, since
|
|
258
|
+
* deleting a live run's provenance would silently drop it to the exact-retry rule (#332).
|
|
259
|
+
*/
|
|
243
260
|
export function orphanedTaskRuntimeProvenance(): string[] {
|
|
244
261
|
let names: string[];
|
|
245
262
|
try { names = readdirSync(join(baseDir(), "task-runtime")); } catch { return []; }
|
|
246
263
|
return names.filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -5))
|
|
247
|
-
.filter((id) => { try { statSync(runDir(id)); return false; } catch { return
|
|
264
|
+
.filter((id) => { try { statSync(runDir(id)); return false; } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; } });
|
|
248
265
|
}
|
|
249
266
|
/**
|
|
250
267
|
* Whether the parent may honour structured intent for this run. `unknown` means the answer could
|