@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.
@@ -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. Design: lossless always if the spill write fails, we
17
- // degrade to today's behavior (full output in context) rather than hard-truncate.
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
- * Path shape: `os.tmpdir()/delegate-output-<sanitized-label>-<6hex>.md`,
58
- * mode 0o600. The label is sanitized (subagent precedent) so agent names
59
- * can't escape the filename. `suffix` (and `dir`) are injectable so tests get
60
- * deterministic, collision-free names and can point at an unwritable dir to
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: string = randomBytes(3).toString("hex"),
101
+ suffix?: string,
67
102
  dir: string = os.tmpdir(),
68
103
  ): string | null {
69
- const safeLabel = label.replace(/[^\w.-]+/g, "_");
70
- const filePath = path.join(dir, `delegate-output-${safeLabel}-${suffix}.md`);
71
- try {
72
- fs.writeFileSync(filePath, output, { mode: 0o600 });
73
- return filePath;
74
- } catch (e) {
75
- console.warn(
76
- `[delegate] spill write failed (${filePath}): ${e instanceof Error ? e.message : String(e)}`,
77
- );
78
- return null;
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
- * a note pointing to the eventual spill. Under the tail budget → unchanged.
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
- return `…${tail}\n[truncated — full output is spilled to a file when the ticket completes]`;
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
  /**
@@ -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
- // Modern overrides come from delegate.json. A one-release bridge below also
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/settings model
430
- // overrides for backwards compatibility. The other built-ins accept
431
- // task and settings.json model overrides, but deliberately ignore the
432
- // legacy delegate.json agent model map so they inherit the parent unless
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
- model = pooledConfig.model;
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, settings, or named-agent frontmatter. If it cannot
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
- agentName: agent?.name ?? "ad-hoc",
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.error === "Aborted" ? "cancelled" : "failed";
698
+ return result.failureKind === "cancelled" ? "cancelled" : "failed";
699
699
  }
700
700
  return "success";
701
701
  }