@bermudi/pi-delegate 0.1.13 → 0.1.15
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 +14 -17
- package/delegate.ts +0 -8
- package/dispatch.ts +25 -2
- package/extension.ts +14 -6
- package/file-tracking.ts +286 -15
- package/format.ts +107 -21
- package/isolated-workspace.ts +550 -62
- package/key-hints.ts +38 -0
- package/lifecycle.ts +559 -80
- package/manual.ts +1 -1
- package/model.ts +6 -1
- package/package.json +13 -9
- package/pool.ts +37 -6
- package/quiescence.ts +19 -6
- package/render-branches.ts +243 -85
- package/render-result.ts +116 -16
- package/runner.ts +361 -82
- package/session-quarantine.ts +266 -0
- package/spill.ts +93 -22
- package/task-resolution.ts +63 -50
- package/telemetry.ts +1 -1
- package/ticket-format.ts +15 -9
- package/tickets.ts +36 -14
- package/tools.ts +5 -4
- package/types.ts +92 -8
- package/utils.ts +123 -2
- package/workspace.ts +133 -0
- package/settings.ts +0 -418
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import type { ResolvedTask } from "./types.ts";
|
|
3
|
+
import { canonicalPath } from "./trusted-paths.ts";
|
|
4
|
+
import { resolveCwd } from "./utils.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Internal, non-serialized hand-off for a runner that returned before its
|
|
8
|
+
* AgentSession was proven quiescent. Symbol properties survive object spreads
|
|
9
|
+
* inside delegate, but JSON/tool-result serialization ignores them.
|
|
10
|
+
*/
|
|
11
|
+
const SESSION_QUARANTINE = Symbol("delegate.sessionQuarantine");
|
|
12
|
+
|
|
13
|
+
export interface SessionQuarantine {
|
|
14
|
+
/** Resolves only after background termination and quiescence checks confirm
|
|
15
|
+
* that the abandoned AgentSession can no longer mutate its workspace. */
|
|
16
|
+
safe: Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type WithSessionQuarantine = {
|
|
20
|
+
[SESSION_QUARANTINE]?: SessionQuarantine;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface QuarantineReservation {
|
|
24
|
+
task: ResolvedTask;
|
|
25
|
+
quarantine: SessionQuarantine;
|
|
26
|
+
/** Physical transcript identity used to acquire the abandoned session.
|
|
27
|
+
* This is passed through from acquisition and must never be re-resolved at
|
|
28
|
+
* publication time: a symlink may have changed while the task was running. */
|
|
29
|
+
resumeFromIdentity?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Identity snapshot supplied while a resume transcript lock is held. */
|
|
33
|
+
export interface LockedResumeTranscript {
|
|
34
|
+
/** Stable absolute spelling used even when the transcript does not exist. */
|
|
35
|
+
lexicalPath: string;
|
|
36
|
+
/** Physical target captured once for this acquisition. */
|
|
37
|
+
canonicalPath?: string;
|
|
38
|
+
/** The captured physical target still existed after this call acquired all
|
|
39
|
+
* applicable lexical/physical locks. */
|
|
40
|
+
exists: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Process-local admission reservations for abandoned tasks. A reservation is
|
|
44
|
+
* removed only when its safety proof fulfills; rejection is not evidence that
|
|
45
|
+
* the session stopped mutating. */
|
|
46
|
+
const quarantineReservations = new Map<symbol, QuarantineReservation>();
|
|
47
|
+
|
|
48
|
+
/** Per-transcript locks close the validation-to-dispatch race for resume-only
|
|
49
|
+
* tasks (which have no sessionId and therefore do not use the pool lock). */
|
|
50
|
+
const resumeFromLocks = new Map<string, Promise<void>>();
|
|
51
|
+
|
|
52
|
+
/** Resolve an existing resume transcript to its current physical identity.
|
|
53
|
+
* Admission-time callers use this as a best-effort early rejection only; the
|
|
54
|
+
* lifecycle lock captures and carries the authoritative acquisition identity. */
|
|
55
|
+
export function canonicalResumeFromIdentity(
|
|
56
|
+
resumeFrom: string | undefined,
|
|
57
|
+
): string | undefined {
|
|
58
|
+
return resumeFrom ? canonicalPath(resolveCwd(resumeFrom)) : undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function logObserverFailure(description: string, error: unknown): void {
|
|
62
|
+
try {
|
|
63
|
+
console.error(`[delegate] ${description}`, error);
|
|
64
|
+
} catch {
|
|
65
|
+
// A cleanup observer must never turn a handled background failure into an
|
|
66
|
+
// unhandled rejection, even when a test or host replaces console.error.
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Observe a safety proof without creating a dangling rejecting promise.
|
|
71
|
+
* Callback failures are surfaced and swallowed because cleanup must not become
|
|
72
|
+
* a process-level unhandled rejection. */
|
|
73
|
+
export function observeQuarantineSafety(
|
|
74
|
+
quarantine: SessionQuarantine,
|
|
75
|
+
description: string,
|
|
76
|
+
onSafe: () => void | Promise<void>,
|
|
77
|
+
onFailure?: (error: unknown) => void | Promise<void>,
|
|
78
|
+
): void {
|
|
79
|
+
const invoke = async (
|
|
80
|
+
callback: (() => void | Promise<void>) | undefined,
|
|
81
|
+
): Promise<void> => {
|
|
82
|
+
if (!callback) return;
|
|
83
|
+
try {
|
|
84
|
+
await callback();
|
|
85
|
+
} catch (error) {
|
|
86
|
+
logObserverFailure(`${description} callback failed`, error);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// Both branches fulfill. The final catch is defensive against an unusual
|
|
91
|
+
// Promise implementation or callback plumbing regression.
|
|
92
|
+
void quarantine.safe
|
|
93
|
+
.then(
|
|
94
|
+
() => invoke(onSafe),
|
|
95
|
+
(error) =>
|
|
96
|
+
invoke(
|
|
97
|
+
onFailure
|
|
98
|
+
? () => onFailure(error)
|
|
99
|
+
: () =>
|
|
100
|
+
logObserverFailure(`${description} safety proof failed`, error),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
.catch((error) =>
|
|
104
|
+
logObserverFailure(`${description} observer failed`, error),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Reserve the task's shared-write capability and logical session identities
|
|
109
|
+
* until safe. `resumeFromIdentity` must be the exact canonical path passed to
|
|
110
|
+
* acquisition; publication deliberately performs no mutable-path lookup. */
|
|
111
|
+
export function reserveSessionQuarantine(
|
|
112
|
+
task: ResolvedTask,
|
|
113
|
+
quarantine: SessionQuarantine,
|
|
114
|
+
resumeFromIdentity: string | undefined,
|
|
115
|
+
): void {
|
|
116
|
+
const key = Symbol("quarantined-task");
|
|
117
|
+
quarantineReservations.set(key, {
|
|
118
|
+
task,
|
|
119
|
+
quarantine,
|
|
120
|
+
resumeFromIdentity,
|
|
121
|
+
});
|
|
122
|
+
observeQuarantineSafety(
|
|
123
|
+
quarantine,
|
|
124
|
+
"quarantine admission reservation release",
|
|
125
|
+
() => {
|
|
126
|
+
quarantineReservations.delete(key);
|
|
127
|
+
},
|
|
128
|
+
// Fail closed: a rejected proof retains the reservation indefinitely.
|
|
129
|
+
() => {},
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Snapshot abandoned tasks that admission must treat as still active. */
|
|
134
|
+
export function quarantinedTasks(): ResolvedTask[] {
|
|
135
|
+
return [...quarantineReservations.values()].map(({ task }) => task);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function isSessionIdQuarantined(sessionId: string): boolean {
|
|
139
|
+
for (const { task } of quarantineReservations.values()) {
|
|
140
|
+
if (task.sessionId === sessionId) return true;
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function isResumeFromIdentityQuarantined(identity: string): boolean {
|
|
146
|
+
for (const reservation of quarantineReservations.values()) {
|
|
147
|
+
if (reservation.resumeFromIdentity === identity) return true;
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function isResumeFromQuarantined(resumeFrom: string): boolean {
|
|
153
|
+
const identity = canonicalResumeFromIdentity(resumeFrom);
|
|
154
|
+
return identity ? isResumeFromIdentityQuarantined(identity) : false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function withResumeLockKeys<T>(
|
|
158
|
+
identities: string[],
|
|
159
|
+
fn: () => Promise<T>,
|
|
160
|
+
): Promise<T> {
|
|
161
|
+
const keys = [...new Set(identities)].sort();
|
|
162
|
+
const previous = keys
|
|
163
|
+
.map((identity) => resumeFromLocks.get(identity))
|
|
164
|
+
.filter((pending): pending is Promise<void> => pending !== undefined);
|
|
165
|
+
let release!: () => void;
|
|
166
|
+
const current = new Promise<void>((resolve) => {
|
|
167
|
+
release = resolve;
|
|
168
|
+
});
|
|
169
|
+
// Publish every claim before waiting. A later caller sharing any lexical or
|
|
170
|
+
// physical identity therefore queues behind this owner rather than racing a
|
|
171
|
+
// different key in the set.
|
|
172
|
+
for (const identity of keys) resumeFromLocks.set(identity, current);
|
|
173
|
+
try {
|
|
174
|
+
await Promise.all(previous);
|
|
175
|
+
return await fn();
|
|
176
|
+
} finally {
|
|
177
|
+
release();
|
|
178
|
+
for (const identity of keys) {
|
|
179
|
+
if (resumeFromLocks.get(identity) === current) {
|
|
180
|
+
resumeFromLocks.delete(identity);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Serialize transcript acquisition without trusting a mutable path twice.
|
|
187
|
+
*
|
|
188
|
+
* Every call claims its stable absolute lexical spelling, so missing and
|
|
189
|
+
* unresolvable paths never fail open. Existing targets additionally claim the
|
|
190
|
+
* pre-wait canonical snapshot, which serializes symlink aliases and pins a
|
|
191
|
+
* queued call to the target it named when it entered. If a formerly missing
|
|
192
|
+
* path becomes resolvable while waiting, its new physical key is claimed
|
|
193
|
+
* before the callback runs. Existence is then checked under all applicable
|
|
194
|
+
* locks and the same canonical path is passed through to acquisition. */
|
|
195
|
+
export async function withResumeTranscriptLock<T>(
|
|
196
|
+
resumeFrom: string | undefined,
|
|
197
|
+
fn: (transcript: LockedResumeTranscript | undefined) => Promise<T>,
|
|
198
|
+
): Promise<T> {
|
|
199
|
+
if (!resumeFrom) return fn(undefined);
|
|
200
|
+
|
|
201
|
+
const lexicalPath = resolveCwd(resumeFrom);
|
|
202
|
+
const preWaitCanonicalPath = canonicalPath(lexicalPath);
|
|
203
|
+
const initialKeys = preWaitCanonicalPath
|
|
204
|
+
? [lexicalPath, preWaitCanonicalPath]
|
|
205
|
+
: [lexicalPath];
|
|
206
|
+
|
|
207
|
+
return withResumeLockKeys(initialKeys, async () => {
|
|
208
|
+
// Preserve an existing target captured before waiting. Only retry
|
|
209
|
+
// canonicalization when there was no target to capture at call entry.
|
|
210
|
+
const canonicalPathOnce =
|
|
211
|
+
preWaitCanonicalPath ?? canonicalPath(lexicalPath);
|
|
212
|
+
const invoke = async (): Promise<T> =>
|
|
213
|
+
fn({
|
|
214
|
+
lexicalPath,
|
|
215
|
+
canonicalPath: canonicalPathOnce,
|
|
216
|
+
exists:
|
|
217
|
+
canonicalPathOnce !== undefined && existsSync(canonicalPathOnce),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// A missing/broken symlink can become a physical alias while queued. Claim
|
|
221
|
+
// that identity before validation/acquisition so it cannot race an owner
|
|
222
|
+
// that entered through the physical path or another alias.
|
|
223
|
+
if (canonicalPathOnce && !initialKeys.includes(canonicalPathOnce)) {
|
|
224
|
+
return withResumeLockKeys([canonicalPathOnce], invoke);
|
|
225
|
+
}
|
|
226
|
+
return invoke();
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @internal Test-only reset. Existing safety observers are token-scoped, so a
|
|
231
|
+
* late fulfillment cannot delete a reservation created after the reset. */
|
|
232
|
+
export function _resetQuarantineRegistryForTesting(): void {
|
|
233
|
+
quarantineReservations.clear();
|
|
234
|
+
resumeFromLocks.clear();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function markSessionQuarantined<T extends object>(
|
|
238
|
+
value: T,
|
|
239
|
+
quarantine: SessionQuarantine,
|
|
240
|
+
): T & WithSessionQuarantine {
|
|
241
|
+
Object.defineProperty(value, SESSION_QUARANTINE, {
|
|
242
|
+
value: quarantine,
|
|
243
|
+
enumerable: true,
|
|
244
|
+
configurable: false,
|
|
245
|
+
writable: false,
|
|
246
|
+
});
|
|
247
|
+
return value as T & WithSessionQuarantine;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function sessionQuarantineOf(
|
|
251
|
+
value: object | undefined,
|
|
252
|
+
): SessionQuarantine | undefined {
|
|
253
|
+
return value
|
|
254
|
+
? (value as WithSessionQuarantine)[SESSION_QUARANTINE]
|
|
255
|
+
: undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Preserve quarantine across explicit result projections that do not use an
|
|
259
|
+
* object spread. */
|
|
260
|
+
export function propagateSessionQuarantine<T extends object>(
|
|
261
|
+
source: object,
|
|
262
|
+
target: T,
|
|
263
|
+
): T {
|
|
264
|
+
const quarantine = sessionQuarantineOf(source);
|
|
265
|
+
return quarantine ? markSessionQuarantined(target, quarantine) : target;
|
|
266
|
+
}
|
package/spill.ts
CHANGED
|
@@ -4,6 +4,34 @@ import * as os from "node:os";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { getOutputSpillTail, getOutputSpillThreshold } from "./config.ts";
|
|
6
6
|
|
|
7
|
+
const SPILL_PREFIX = "delegate-output-";
|
|
8
|
+
const RANDOM_SUFFIX_BYTES = 16;
|
|
9
|
+
const CREATE_ATTEMPTS = 5;
|
|
10
|
+
|
|
11
|
+
type SpillFileOperations = {
|
|
12
|
+
open: (filePath: string, flags: string, mode: number) => number;
|
|
13
|
+
write: (fd: number, output: string) => void;
|
|
14
|
+
close: (fd: number) => void;
|
|
15
|
+
remove: (filePath: string) => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const defaultSpillFileOperations: SpillFileOperations = {
|
|
19
|
+
open: (filePath, flags, mode) => fs.openSync(filePath, flags, mode),
|
|
20
|
+
write: (fd, output) => fs.writeFileSync(fd, output),
|
|
21
|
+
close: (fd) => fs.closeSync(fd),
|
|
22
|
+
remove: (filePath) => fs.rmSync(filePath, { force: true }),
|
|
23
|
+
};
|
|
24
|
+
let spillFileOperations = defaultSpillFileOperations;
|
|
25
|
+
|
|
26
|
+
/** @internal Test-only I/O seam for deterministic write/cleanup failures. */
|
|
27
|
+
export function _setSpillFileOperationsForTesting(
|
|
28
|
+
overrides: Partial<SpillFileOperations> | undefined,
|
|
29
|
+
): void {
|
|
30
|
+
spillFileOperations = overrides
|
|
31
|
+
? { ...defaultSpillFileOperations, ...overrides }
|
|
32
|
+
: defaultSpillFileOperations;
|
|
33
|
+
}
|
|
34
|
+
|
|
7
35
|
// ── Spill: keep subagent final-output bloat out of the LLM context ───────
|
|
8
36
|
//
|
|
9
37
|
// Two audiences share one source of truth (`result.output`):
|
|
@@ -13,8 +41,10 @@ import { getOutputSpillTail, getOutputSpillThreshold } from "./config.ts";
|
|
|
13
41
|
//
|
|
14
42
|
// The spill is a greppable plain-text `.md` projection of the *final output*
|
|
15
43
|
// only. The full transcript already lives in the session `.jsonl`; this does
|
|
16
|
-
// not duplicate it.
|
|
17
|
-
//
|
|
44
|
+
// not duplicate it. Delegate never deletes spills because their pointers can
|
|
45
|
+
// persist in transcripts; lifecycle is left to the OS temp policy. Design:
|
|
46
|
+
// lossless always — if the spill write fails, we degrade to today's behavior
|
|
47
|
+
// (full output in context) rather than hard-truncate.
|
|
18
48
|
|
|
19
49
|
/** Decision over an output string: spill or not, and what stays in-context. */
|
|
20
50
|
export interface SpillDecision {
|
|
@@ -50,33 +80,68 @@ export function decideSpill(
|
|
|
50
80
|
return { spill: true, inContext: tailOf(output, opts.tailChars), fullChars };
|
|
51
81
|
}
|
|
52
82
|
|
|
83
|
+
function errorCode(error: unknown): unknown {
|
|
84
|
+
return typeof error === "object" && error !== null && "code" in error
|
|
85
|
+
? (error as { code?: unknown }).code
|
|
86
|
+
: undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
53
89
|
/**
|
|
54
90
|
* Write the full output to a temp `.md` file and return its path, or `null`
|
|
55
91
|
* on failure. Never throws — callers rely on the lossless-degrade guarantee.
|
|
56
92
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* exercise the failure path without mocking.
|
|
93
|
+
* Files are mode 0o600 and opened with `wx`: even an improbable collision can
|
|
94
|
+
* never overwrite another spill. Production names use 128 bits of randomness
|
|
95
|
+
* and retry collisions. `suffix` and `dir` remain injectable for focused I/O
|
|
96
|
+
* tests; a supplied suffix is attempted exactly once.
|
|
62
97
|
*/
|
|
63
98
|
export function spillToTempFile(
|
|
64
99
|
output: string,
|
|
65
100
|
label: string,
|
|
66
|
-
suffix
|
|
101
|
+
suffix?: string,
|
|
67
102
|
dir: string = os.tmpdir(),
|
|
68
103
|
): string | null {
|
|
69
|
-
const safeLabel = label.replace(/[^\w.-]+/g, "_");
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
104
|
+
const safeLabel = label.replace(/[^\w.-]+/g, "_").slice(0, 64) || "agent";
|
|
105
|
+
const attempts = suffix === undefined ? CREATE_ATTEMPTS : 1;
|
|
106
|
+
let lastPath = path.join(dir, `${SPILL_PREFIX}${safeLabel}-unknown.md`);
|
|
107
|
+
let lastError: unknown;
|
|
108
|
+
|
|
109
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
110
|
+
let fd: number | undefined;
|
|
111
|
+
try {
|
|
112
|
+
const candidate =
|
|
113
|
+
suffix ?? randomBytes(RANDOM_SUFFIX_BYTES).toString("hex");
|
|
114
|
+
lastPath = path.join(dir, `${SPILL_PREFIX}${safeLabel}-${candidate}.md`);
|
|
115
|
+
fd = spillFileOperations.open(lastPath, "wx", 0o600);
|
|
116
|
+
spillFileOperations.write(fd, output);
|
|
117
|
+
spillFileOperations.close(fd);
|
|
118
|
+
return lastPath;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
lastError = error;
|
|
121
|
+
if (fd !== undefined) {
|
|
122
|
+
try {
|
|
123
|
+
spillFileOperations.close(fd);
|
|
124
|
+
} catch {
|
|
125
|
+
// Continue to remove the incomplete file below.
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
spillFileOperations.remove(lastPath);
|
|
129
|
+
} catch (cleanupError) {
|
|
130
|
+
console.warn(
|
|
131
|
+
`[delegate] spill partial-file cleanup failed (${lastPath}): ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const code = errorCode(error);
|
|
136
|
+
if (suffix === undefined && code === "EEXIST") continue;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
79
139
|
}
|
|
140
|
+
|
|
141
|
+
console.warn(
|
|
142
|
+
`[delegate] spill write failed (${lastPath}): ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
|
143
|
+
);
|
|
144
|
+
return null;
|
|
80
145
|
}
|
|
81
146
|
|
|
82
147
|
/**
|
|
@@ -119,17 +184,23 @@ export function renderOutputForLLM(
|
|
|
119
184
|
* The output is a moving target mid-flight (a done task's full spill lands at
|
|
120
185
|
* ticket completion via `formatCompletedTask`); writing a file per poll would
|
|
121
186
|
* churn paths and confuse the LLM. So the poll stays bounded with a tail and
|
|
122
|
-
*
|
|
187
|
+
* accurately says whether completion will spill or include the full output.
|
|
188
|
+
* Under the tail budget → unchanged.
|
|
123
189
|
*/
|
|
124
190
|
export function renderOutputForPoll(
|
|
125
191
|
output: string,
|
|
126
|
-
opts?: { tailChars?: number },
|
|
192
|
+
opts?: { tailChars?: number; thresholdChars?: number },
|
|
127
193
|
): string {
|
|
128
194
|
if (!output || !output.trim() || output === "(no output)") return output;
|
|
129
195
|
const tailChars = opts?.tailChars ?? getOutputSpillTail();
|
|
130
196
|
if (output.length <= tailChars) return output;
|
|
197
|
+
const thresholdChars = opts?.thresholdChars ?? getOutputSpillThreshold();
|
|
131
198
|
const tail = tailOf(output, tailChars);
|
|
132
|
-
|
|
199
|
+
const completionNote =
|
|
200
|
+
output.length > thresholdChars
|
|
201
|
+
? "full output will spill to a file if possible when the ticket completes, with full in-result inclusion as the fallback"
|
|
202
|
+
: "full output will be included when the ticket completes";
|
|
203
|
+
return `…${tail}\n[truncated in this poll — ${completionNote}]`;
|
|
133
204
|
}
|
|
134
205
|
|
|
135
206
|
/** Assemble the tail + pointer block emitted on a successful spill. */
|
|
@@ -138,7 +209,7 @@ function spillPointer(
|
|
|
138
209
|
filePath: string,
|
|
139
210
|
fullChars: number,
|
|
140
211
|
): string {
|
|
141
|
-
return `…${tail}\n\n[full output (${humanSize(fullChars)}) spilled to ${filePath} —\n \`read\`/\`grep\` it if completeness matters here; above is the tail]`;
|
|
212
|
+
return `…${tail}\n\n[full output (${humanSize(fullChars)}) spilled to ${filePath} —\n retention follows OS temp policy; \`read\`/\`grep\` it if completeness matters here; above is the tail]`;
|
|
142
213
|
}
|
|
143
214
|
|
|
144
215
|
/**
|
package/task-resolution.ts
CHANGED
|
@@ -13,7 +13,12 @@ import {
|
|
|
13
13
|
} from "./tools.ts";
|
|
14
14
|
import { configFor } from "./pool.ts";
|
|
15
15
|
import { isSessionBusy } from "./tickets.ts";
|
|
16
|
+
import {
|
|
17
|
+
isResumeFromQuarantined,
|
|
18
|
+
isSessionIdQuarantined,
|
|
19
|
+
} from "./session-quarantine.ts";
|
|
16
20
|
import { BUILTIN_AGENT_CONFIGS, buildSubagentSystemPrompt } from "./agents.ts";
|
|
21
|
+
import { formatResumeTag } from "./format.ts";
|
|
17
22
|
import { buildParentTranscript } from "./parent-context.ts";
|
|
18
23
|
import { findAvailableAlternative, resolveModelRequest } from "./model.ts";
|
|
19
24
|
import {
|
|
@@ -25,10 +30,6 @@ import {
|
|
|
25
30
|
} from "./config.ts";
|
|
26
31
|
import type { DelegateConfig } from "./config.ts";
|
|
27
32
|
import { resolveCwd } from "./utils.ts";
|
|
28
|
-
import {
|
|
29
|
-
loadDelegateSettings,
|
|
30
|
-
warnLegacyDelegateSettingsMoved,
|
|
31
|
-
} from "./settings.ts";
|
|
32
33
|
import type {
|
|
33
34
|
AgentConfig,
|
|
34
35
|
DelegateToolCtx,
|
|
@@ -171,6 +172,32 @@ export function validateTasks(
|
|
|
171
172
|
);
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
// An abandoned session is detached from the pool, but reusing its caller key
|
|
176
|
+
// before background safety confirmation would create a second live owner for
|
|
177
|
+
// the same logical session in both sync and async dispatches.
|
|
178
|
+
const quarantinedSessions = sessionIds.filter(isSessionIdQuarantined);
|
|
179
|
+
if (quarantinedSessions.length) {
|
|
180
|
+
return noticeResult(
|
|
181
|
+
`SessionId(s) quarantined after abandonment: ${quarantinedSessions.join(", ")}. Wait for background safety confirmation before reusing each sessionId.`,
|
|
182
|
+
tasks,
|
|
183
|
+
parentModelId,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const quarantinedResumes = tasks
|
|
188
|
+
.map((task) => task.resumeFrom)
|
|
189
|
+
.filter(
|
|
190
|
+
(resumeFrom): resumeFrom is string =>
|
|
191
|
+
resumeFrom !== undefined && isResumeFromQuarantined(resumeFrom),
|
|
192
|
+
);
|
|
193
|
+
if (quarantinedResumes.length) {
|
|
194
|
+
return noticeResult(
|
|
195
|
+
`resumeFrom transcript(s) quarantined after abandonment: ${[...new Set(quarantinedResumes)].join(", ")}. Wait for background safety confirmation before resuming each transcript.`,
|
|
196
|
+
tasks,
|
|
197
|
+
parentModelId,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
174
201
|
// Disallow sessionIds already claimed by a running async ticket.
|
|
175
202
|
const busyConflicts: string[] = [];
|
|
176
203
|
for (const sid of sessionIds) {
|
|
@@ -238,9 +265,7 @@ export function resolveTasks(
|
|
|
238
265
|
ctx.getSystemPrompt?.(),
|
|
239
266
|
);
|
|
240
267
|
|
|
241
|
-
//
|
|
242
|
-
// reads legacy settings.json model/thinking values; modern values win
|
|
243
|
-
// field-by-field and legacy tools are never honored.
|
|
268
|
+
// Agent overrides come exclusively from the user-scoped delegate.json.
|
|
244
269
|
const agentOverrides = getAgentOverrides(dispatchConfig);
|
|
245
270
|
const overridesByParentModel = getAgentOverridesByParentModel(dispatchConfig);
|
|
246
271
|
|
|
@@ -252,13 +277,6 @@ export function resolveTasks(
|
|
|
252
277
|
const isBuiltinAgent = agent?.builtin === true;
|
|
253
278
|
const cwd = resolveCwd(t.cwd ?? ctx.cwd, ctx.cwd);
|
|
254
279
|
|
|
255
|
-
// A task can resolve to a different cwd than the parent; legacy
|
|
256
|
-
// `delegate` blocks in those project settings must be surfaced too.
|
|
257
|
-
warnLegacyDelegateSettingsMoved(cwd, (message) =>
|
|
258
|
-
ctx.ui?.notify(message, "warning"),
|
|
259
|
-
);
|
|
260
|
-
const legacySettings = loadDelegateSettings(cwd);
|
|
261
|
-
|
|
262
280
|
// delegate.json agent overrides for this agent. `default` bypasses them
|
|
263
281
|
// entirely (it mirrors the live parent by contract).
|
|
264
282
|
const parentModelKey = ctx.model
|
|
@@ -275,20 +293,6 @@ export function resolveTasks(
|
|
|
275
293
|
t.agent && !isDefaultAgent
|
|
276
294
|
? getOwnMapValue(agentOverrides, t.agent)
|
|
277
295
|
: undefined;
|
|
278
|
-
const legacyParentModelOverride =
|
|
279
|
-
t.agent && !isDefaultAgent && parentModelKey
|
|
280
|
-
? getOwnMapValue(
|
|
281
|
-
getOwnMapValue(
|
|
282
|
-
legacySettings?.agentOverridesByParentModel,
|
|
283
|
-
parentModelKey,
|
|
284
|
-
),
|
|
285
|
-
t.agent,
|
|
286
|
-
)
|
|
287
|
-
: undefined;
|
|
288
|
-
const legacyAgentOverride =
|
|
289
|
-
t.agent && !isDefaultAgent
|
|
290
|
-
? getOwnMapValue(legacySettings?.agentOverrides, t.agent)
|
|
291
|
-
: undefined;
|
|
292
296
|
|
|
293
297
|
// Build system prompt. Explicit task prompts and named agent prompts
|
|
294
298
|
// win; ad-hoc subagents inherit the parent's base prompt when Pi exposes
|
|
@@ -426,11 +430,10 @@ export function resolveTasks(
|
|
|
426
430
|
|
|
427
431
|
if (t.sessionAction !== "close" && t.sessionAction !== "list") {
|
|
428
432
|
const agentType = t.agent ?? "inline";
|
|
429
|
-
// The built-in `default` profile bypasses delegate
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
// an explicit modern override wins.
|
|
433
|
+
// The built-in `default` profile bypasses delegate.json model overrides.
|
|
434
|
+
// The other built-ins accept task and modern agent overrides, but
|
|
435
|
+
// deliberately ignore the legacy delegate.json agent model map so they
|
|
436
|
+
// inherit the parent unless an explicit modern override wins.
|
|
434
437
|
// Overridden built-ins can still provide an explicit `model` in their
|
|
435
438
|
// Markdown frontmatter – when `explicitModel` is set, honor it instead
|
|
436
439
|
// of silently ignoring it (which would contradict the Markdown contract).
|
|
@@ -440,16 +443,10 @@ export function resolveTasks(
|
|
|
440
443
|
? (t.model ??
|
|
441
444
|
parentModelOverride?.model ??
|
|
442
445
|
agentOverride?.model ??
|
|
443
|
-
legacyParentModelOverride?.model ??
|
|
444
|
-
legacyAgentOverride?.model ??
|
|
445
446
|
(agent?.explicitModel ? agent.model : undefined))
|
|
446
447
|
: resolveModelSpec({
|
|
447
448
|
taskModel:
|
|
448
|
-
t.model ??
|
|
449
|
-
parentModelOverride?.model ??
|
|
450
|
-
agentOverride?.model ??
|
|
451
|
-
legacyParentModelOverride?.model ??
|
|
452
|
-
legacyAgentOverride?.model,
|
|
449
|
+
t.model ?? parentModelOverride?.model ?? agentOverride?.model,
|
|
453
450
|
agentType,
|
|
454
451
|
frontmatterModel: agent?.model,
|
|
455
452
|
config: dispatchConfig,
|
|
@@ -477,7 +474,16 @@ export function resolveTasks(
|
|
|
477
474
|
} else if (isDefaultAgent) {
|
|
478
475
|
requestedModel = ctx.model;
|
|
479
476
|
}
|
|
480
|
-
|
|
477
|
+
const frozenModel = pooledConfig.model;
|
|
478
|
+
// configFor() returns a defensive clone. Recover the registry's
|
|
479
|
+
// canonical instance for downstream identity compatibility while the
|
|
480
|
+
// pool keeps validating against its immutable value snapshot.
|
|
481
|
+
model =
|
|
482
|
+
ctx.model?.provider === frozenModel.provider &&
|
|
483
|
+
ctx.model.id === frozenModel.id
|
|
484
|
+
? ctx.model
|
|
485
|
+
: (ctx.modelRegistry.find(frozenModel.provider, frozenModel.id) ??
|
|
486
|
+
frozenModel);
|
|
481
487
|
} else {
|
|
482
488
|
const resolvedRequest = modelSpec
|
|
483
489
|
? resolveModelRequest(modelSpec, ctx.modelRegistry, ctx.model)
|
|
@@ -488,7 +494,7 @@ export function resolveTasks(
|
|
|
488
494
|
modelSuffix = resolvedRequest?.strippedSuffix;
|
|
489
495
|
|
|
490
496
|
// The selected model spec is explicit regardless of whether it came
|
|
491
|
-
// from the task,
|
|
497
|
+
// from the task, delegate config, or named-agent frontmatter. If it cannot
|
|
492
498
|
// resolve, fail loudly instead of silently falling back to the parent.
|
|
493
499
|
const explicitRequest = modelSpec;
|
|
494
500
|
if (explicitRequest && !resolvedModel) {
|
|
@@ -524,8 +530,6 @@ export function resolveTasks(
|
|
|
524
530
|
? (t.thinking ??
|
|
525
531
|
parentModelOverride?.thinking ??
|
|
526
532
|
agentOverride?.thinking ??
|
|
527
|
-
legacyParentModelOverride?.thinking ??
|
|
528
|
-
legacyAgentOverride?.thinking ??
|
|
529
533
|
(agent?.explicitThinking ? agent.thinking : undefined) ??
|
|
530
534
|
modelSuffix ??
|
|
531
535
|
parentDefaults.thinking ??
|
|
@@ -534,8 +538,6 @@ export function resolveTasks(
|
|
|
534
538
|
: (t.thinking ??
|
|
535
539
|
parentModelOverride?.thinking ??
|
|
536
540
|
agentOverride?.thinking ??
|
|
537
|
-
legacyParentModelOverride?.thinking ??
|
|
538
|
-
legacyAgentOverride?.thinking ??
|
|
539
541
|
(agent?.explicitThinking ? agent.thinking : undefined) ??
|
|
540
542
|
(isPoolHit ? pooledConfig?.thinking : undefined) ??
|
|
541
543
|
modelSuffix ??
|
|
@@ -544,8 +546,6 @@ export function resolveTasks(
|
|
|
544
546
|
: (t.thinking ??
|
|
545
547
|
parentModelOverride?.thinking ??
|
|
546
548
|
agentOverride?.thinking ??
|
|
547
|
-
legacyParentModelOverride?.thinking ??
|
|
548
|
-
legacyAgentOverride?.thinking ??
|
|
549
549
|
agent?.thinking ??
|
|
550
550
|
(isPoolHit ? pooledConfig?.thinking : undefined) ??
|
|
551
551
|
modelSuffix ??
|
|
@@ -592,6 +592,14 @@ export function resolveTasks(
|
|
|
592
592
|
requestedSystemPrompt = systemPrompt;
|
|
593
593
|
}
|
|
594
594
|
|
|
595
|
+
// Freeze the display tag from the caller's path *before* lifecycle
|
|
596
|
+
// canonicalizes `resumeFrom` for locking/acquisition. A symlink whose
|
|
597
|
+
// basename differs from its target would otherwise make the settled row's
|
|
598
|
+
// `resumedFrom` disagree with the live progress row and `agentName`,
|
|
599
|
+
// defeating `resumeMarker`'s no-duplication rule.
|
|
600
|
+
const resumeFromDisplay = t.resumeFrom
|
|
601
|
+
? formatResumeTag(t.resumeFrom)
|
|
602
|
+
: undefined;
|
|
595
603
|
return {
|
|
596
604
|
...t,
|
|
597
605
|
id: t.id,
|
|
@@ -605,8 +613,13 @@ export function resolveTasks(
|
|
|
605
613
|
// display code treats "" and absent alike (`t.prompt || …`).
|
|
606
614
|
prompt: prompt ?? "",
|
|
607
615
|
// Keep the built-in selector visible in progress/results. Omitted-agent
|
|
608
|
-
// inline tasks retain the established `ad-hoc` label and config namespace
|
|
609
|
-
|
|
616
|
+
// inline tasks retain the established `ad-hoc` label and config namespace
|
|
617
|
+
// — except resumes: a continued transcript is not a fresh ad-hoc spawn,
|
|
618
|
+
// so it carries the resumed-transcript identity instead.
|
|
619
|
+
agentName:
|
|
620
|
+
agent?.name ??
|
|
621
|
+
(resumeFromDisplay ? `resume:${resumeFromDisplay}` : "ad-hoc"),
|
|
622
|
+
resumeFromDisplay,
|
|
610
623
|
warnings,
|
|
611
624
|
reuseIntent: {
|
|
612
625
|
model: requestedModel,
|
package/telemetry.ts
CHANGED
|
@@ -695,7 +695,7 @@ export interface TaskSpanInput {
|
|
|
695
695
|
|
|
696
696
|
function outcomeFromResult(result: TaskResult): string {
|
|
697
697
|
if (result.error) {
|
|
698
|
-
return result.
|
|
698
|
+
return result.failureKind === "cancelled" ? "cancelled" : "failed";
|
|
699
699
|
}
|
|
700
700
|
return "success";
|
|
701
701
|
}
|