@bermudi/pi-delegate 0.1.13 → 0.1.14
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 +24 -2
- package/extension.ts +14 -6
- package/file-tracking.ts +286 -15
- package/format.ts +79 -20
- package/isolated-workspace.ts +550 -62
- package/key-hints.ts +38 -0
- package/lifecycle.ts +555 -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 +232 -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 +47 -48
- package/telemetry.ts +1 -1
- package/ticket-format.ts +8 -3
- package/tickets.ts +33 -13
- package/tools.ts +5 -4
- package/types.ts +77 -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,6 +13,10 @@ 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";
|
|
17
21
|
import { buildParentTranscript } from "./parent-context.ts";
|
|
18
22
|
import { findAvailableAlternative, resolveModelRequest } from "./model.ts";
|
|
@@ -25,10 +29,6 @@ import {
|
|
|
25
29
|
} from "./config.ts";
|
|
26
30
|
import type { DelegateConfig } from "./config.ts";
|
|
27
31
|
import { resolveCwd } from "./utils.ts";
|
|
28
|
-
import {
|
|
29
|
-
loadDelegateSettings,
|
|
30
|
-
warnLegacyDelegateSettingsMoved,
|
|
31
|
-
} from "./settings.ts";
|
|
32
32
|
import type {
|
|
33
33
|
AgentConfig,
|
|
34
34
|
DelegateToolCtx,
|
|
@@ -171,6 +171,32 @@ export function validateTasks(
|
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// An abandoned session is detached from the pool, but reusing its caller key
|
|
175
|
+
// before background safety confirmation would create a second live owner for
|
|
176
|
+
// the same logical session in both sync and async dispatches.
|
|
177
|
+
const quarantinedSessions = sessionIds.filter(isSessionIdQuarantined);
|
|
178
|
+
if (quarantinedSessions.length) {
|
|
179
|
+
return noticeResult(
|
|
180
|
+
`SessionId(s) quarantined after abandonment: ${quarantinedSessions.join(", ")}. Wait for background safety confirmation before reusing each sessionId.`,
|
|
181
|
+
tasks,
|
|
182
|
+
parentModelId,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const quarantinedResumes = tasks
|
|
187
|
+
.map((task) => task.resumeFrom)
|
|
188
|
+
.filter(
|
|
189
|
+
(resumeFrom): resumeFrom is string =>
|
|
190
|
+
resumeFrom !== undefined && isResumeFromQuarantined(resumeFrom),
|
|
191
|
+
);
|
|
192
|
+
if (quarantinedResumes.length) {
|
|
193
|
+
return noticeResult(
|
|
194
|
+
`resumeFrom transcript(s) quarantined after abandonment: ${[...new Set(quarantinedResumes)].join(", ")}. Wait for background safety confirmation before resuming each transcript.`,
|
|
195
|
+
tasks,
|
|
196
|
+
parentModelId,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
174
200
|
// Disallow sessionIds already claimed by a running async ticket.
|
|
175
201
|
const busyConflicts: string[] = [];
|
|
176
202
|
for (const sid of sessionIds) {
|
|
@@ -238,9 +264,7 @@ export function resolveTasks(
|
|
|
238
264
|
ctx.getSystemPrompt?.(),
|
|
239
265
|
);
|
|
240
266
|
|
|
241
|
-
//
|
|
242
|
-
// reads legacy settings.json model/thinking values; modern values win
|
|
243
|
-
// field-by-field and legacy tools are never honored.
|
|
267
|
+
// Agent overrides come exclusively from the user-scoped delegate.json.
|
|
244
268
|
const agentOverrides = getAgentOverrides(dispatchConfig);
|
|
245
269
|
const overridesByParentModel = getAgentOverridesByParentModel(dispatchConfig);
|
|
246
270
|
|
|
@@ -252,13 +276,6 @@ export function resolveTasks(
|
|
|
252
276
|
const isBuiltinAgent = agent?.builtin === true;
|
|
253
277
|
const cwd = resolveCwd(t.cwd ?? ctx.cwd, ctx.cwd);
|
|
254
278
|
|
|
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
279
|
// delegate.json agent overrides for this agent. `default` bypasses them
|
|
263
280
|
// entirely (it mirrors the live parent by contract).
|
|
264
281
|
const parentModelKey = ctx.model
|
|
@@ -275,20 +292,6 @@ export function resolveTasks(
|
|
|
275
292
|
t.agent && !isDefaultAgent
|
|
276
293
|
? getOwnMapValue(agentOverrides, t.agent)
|
|
277
294
|
: 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
295
|
|
|
293
296
|
// Build system prompt. Explicit task prompts and named agent prompts
|
|
294
297
|
// win; ad-hoc subagents inherit the parent's base prompt when Pi exposes
|
|
@@ -426,11 +429,10 @@ export function resolveTasks(
|
|
|
426
429
|
|
|
427
430
|
if (t.sessionAction !== "close" && t.sessionAction !== "list") {
|
|
428
431
|
const agentType = t.agent ?? "inline";
|
|
429
|
-
// The built-in `default` profile bypasses delegate
|
|
430
|
-
//
|
|
431
|
-
//
|
|
432
|
-
//
|
|
433
|
-
// an explicit modern override wins.
|
|
432
|
+
// The built-in `default` profile bypasses delegate.json model overrides.
|
|
433
|
+
// The other built-ins accept task and modern agent overrides, but
|
|
434
|
+
// deliberately ignore the legacy delegate.json agent model map so they
|
|
435
|
+
// inherit the parent unless an explicit modern override wins.
|
|
434
436
|
// Overridden built-ins can still provide an explicit `model` in their
|
|
435
437
|
// Markdown frontmatter – when `explicitModel` is set, honor it instead
|
|
436
438
|
// of silently ignoring it (which would contradict the Markdown contract).
|
|
@@ -440,16 +442,10 @@ export function resolveTasks(
|
|
|
440
442
|
? (t.model ??
|
|
441
443
|
parentModelOverride?.model ??
|
|
442
444
|
agentOverride?.model ??
|
|
443
|
-
legacyParentModelOverride?.model ??
|
|
444
|
-
legacyAgentOverride?.model ??
|
|
445
445
|
(agent?.explicitModel ? agent.model : undefined))
|
|
446
446
|
: resolveModelSpec({
|
|
447
447
|
taskModel:
|
|
448
|
-
t.model ??
|
|
449
|
-
parentModelOverride?.model ??
|
|
450
|
-
agentOverride?.model ??
|
|
451
|
-
legacyParentModelOverride?.model ??
|
|
452
|
-
legacyAgentOverride?.model,
|
|
448
|
+
t.model ?? parentModelOverride?.model ?? agentOverride?.model,
|
|
453
449
|
agentType,
|
|
454
450
|
frontmatterModel: agent?.model,
|
|
455
451
|
config: dispatchConfig,
|
|
@@ -477,7 +473,16 @@ export function resolveTasks(
|
|
|
477
473
|
} else if (isDefaultAgent) {
|
|
478
474
|
requestedModel = ctx.model;
|
|
479
475
|
}
|
|
480
|
-
|
|
476
|
+
const frozenModel = pooledConfig.model;
|
|
477
|
+
// configFor() returns a defensive clone. Recover the registry's
|
|
478
|
+
// canonical instance for downstream identity compatibility while the
|
|
479
|
+
// pool keeps validating against its immutable value snapshot.
|
|
480
|
+
model =
|
|
481
|
+
ctx.model?.provider === frozenModel.provider &&
|
|
482
|
+
ctx.model.id === frozenModel.id
|
|
483
|
+
? ctx.model
|
|
484
|
+
: (ctx.modelRegistry.find(frozenModel.provider, frozenModel.id) ??
|
|
485
|
+
frozenModel);
|
|
481
486
|
} else {
|
|
482
487
|
const resolvedRequest = modelSpec
|
|
483
488
|
? resolveModelRequest(modelSpec, ctx.modelRegistry, ctx.model)
|
|
@@ -488,7 +493,7 @@ export function resolveTasks(
|
|
|
488
493
|
modelSuffix = resolvedRequest?.strippedSuffix;
|
|
489
494
|
|
|
490
495
|
// The selected model spec is explicit regardless of whether it came
|
|
491
|
-
// from the task,
|
|
496
|
+
// from the task, delegate config, or named-agent frontmatter. If it cannot
|
|
492
497
|
// resolve, fail loudly instead of silently falling back to the parent.
|
|
493
498
|
const explicitRequest = modelSpec;
|
|
494
499
|
if (explicitRequest && !resolvedModel) {
|
|
@@ -524,8 +529,6 @@ export function resolveTasks(
|
|
|
524
529
|
? (t.thinking ??
|
|
525
530
|
parentModelOverride?.thinking ??
|
|
526
531
|
agentOverride?.thinking ??
|
|
527
|
-
legacyParentModelOverride?.thinking ??
|
|
528
|
-
legacyAgentOverride?.thinking ??
|
|
529
532
|
(agent?.explicitThinking ? agent.thinking : undefined) ??
|
|
530
533
|
modelSuffix ??
|
|
531
534
|
parentDefaults.thinking ??
|
|
@@ -534,8 +537,6 @@ export function resolveTasks(
|
|
|
534
537
|
: (t.thinking ??
|
|
535
538
|
parentModelOverride?.thinking ??
|
|
536
539
|
agentOverride?.thinking ??
|
|
537
|
-
legacyParentModelOverride?.thinking ??
|
|
538
|
-
legacyAgentOverride?.thinking ??
|
|
539
540
|
(agent?.explicitThinking ? agent.thinking : undefined) ??
|
|
540
541
|
(isPoolHit ? pooledConfig?.thinking : undefined) ??
|
|
541
542
|
modelSuffix ??
|
|
@@ -544,8 +545,6 @@ export function resolveTasks(
|
|
|
544
545
|
: (t.thinking ??
|
|
545
546
|
parentModelOverride?.thinking ??
|
|
546
547
|
agentOverride?.thinking ??
|
|
547
|
-
legacyParentModelOverride?.thinking ??
|
|
548
|
-
legacyAgentOverride?.thinking ??
|
|
549
548
|
agent?.thinking ??
|
|
550
549
|
(isPoolHit ? pooledConfig?.thinking : undefined) ??
|
|
551
550
|
modelSuffix ??
|
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
|
}
|
package/ticket-format.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
formatTouchedOverlapWarning,
|
|
13
13
|
} from "./format.ts";
|
|
14
14
|
import { renderOutputForPoll } from "./spill.ts";
|
|
15
|
-
import { getOutputSpillTail } from "./config.ts";
|
|
15
|
+
import { getOutputSpillTail, getOutputSpillThreshold } from "./config.ts";
|
|
16
16
|
import type {
|
|
17
17
|
AsyncTicket,
|
|
18
18
|
DelegateDetails,
|
|
@@ -120,12 +120,15 @@ function formatSettledPollLines(
|
|
|
120
120
|
const meta = taskMetaBase(result);
|
|
121
121
|
appendTouchedMeta(meta, result, task);
|
|
122
122
|
const tailChars = getOutputSpillTail(ticket.config);
|
|
123
|
+
const thresholdChars = getOutputSpillThreshold(ticket.config);
|
|
123
124
|
if (!failed) {
|
|
124
125
|
const lines = [
|
|
125
126
|
`✓ ${result.agent}${formatTaskId(result.id)} · ${meta.join(" · ")}`,
|
|
126
127
|
];
|
|
127
128
|
if (result.output && result.output !== "(no output)") {
|
|
128
|
-
lines.push(
|
|
129
|
+
lines.push(
|
|
130
|
+
renderOutputForPoll(result.output, { tailChars, thresholdChars }),
|
|
131
|
+
);
|
|
129
132
|
}
|
|
130
133
|
return lines;
|
|
131
134
|
}
|
|
@@ -136,7 +139,9 @@ function formatSettledPollLines(
|
|
|
136
139
|
if (result.sessionFile)
|
|
137
140
|
lines.push(` session: ${shortenPath(result.sessionFile)}`);
|
|
138
141
|
if (result.output && result.output !== "(no output)") {
|
|
139
|
-
lines.push(
|
|
142
|
+
lines.push(
|
|
143
|
+
renderOutputForPoll(result.output, { tailChars, thresholdChars }),
|
|
144
|
+
);
|
|
140
145
|
}
|
|
141
146
|
return lines;
|
|
142
147
|
}
|