Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 1de6ca6

Browse files
committed
Merge PR tintinweb#180: agent startup error status
1 parent bb2a655 commit 1de6ca6

27 files changed

Lines changed: 2262 additions & 302 deletions

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543
1111
## Features
1212

1313
- **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`Agent`, `get_subagent_result`, `steer_subagent`) — feels native
14+
- **Explicit lifecycle contract** — rejected invocations (invalid models/options/IDs or foreground pre-session startup failure) are Pi tool errors and create no recoverable agent. Once a run is accepted, provider/runtime failures, turn-limit endings, and stops remain successful tool responses with a stable `Agent outcome:` block (`status`, `category`, `agent_id`, and recovery policy). `resume_same_agent` is advertised only for runs that have a session; stopped no-session runs forbid a fresh spawn without inventing recovery, while delayed/background pre-session startup failures retain their accepted ID and allow a fresh start only after correcting the startup problem.
1415
- **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4) and smart group join (consolidated notifications)
1516
- **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons. Configurable via `/agents → Settings → Widget`: `all` (every agent), `background` (default — hides foreground runs, which already render inline as the `Agent` tool result), or `off`
1617
- **FleetView** — Claude Code-style navigable list of `main` + every running subagent rendered below the editor (earliest-launched first). Press `` (or ``) at an empty prompt to jump in, ``/`` to move the selection, `Enter` to open the selected agent's live, auto-updating conversation, `Esc` to return. Finished agents linger briefly before dropping out, and a viewer stays open through completion so you can read the final output. Toggle via `/agents → Settings → Fleet view`
@@ -595,7 +596,7 @@ The agent gets a full, isolated copy of the repository. On completion:
595596

596597
The automatic preservation commit uses `--no-verify`, so local pre-commit hooks can't block it — the commit is local-only and never pushed, and pre-push/server-side hooks still apply.
597598

598-
If the worktree cannot be created (not a git repo, no commits, or `git worktree add` fails), the `Agent` tool returns a clear error instead of running unisolated — `isolation: "worktree"` is a strict guarantee, not a hint. Initialize git and commit at least once, or omit `isolation`.
599+
If the worktree cannot be created (not a git repo, no commits, or `git worktree add` fails), the `Agent` tool returns a clear error instead of running unisolated — `isolation: "worktree"` is a strict guarantee, not a hint. Omit `isolation` when an unisolated run is appropriate; never initialize or commit a repository solely to enable worktree isolation.
599600

600601
## Skill Preloading
601602

examples/agent-tool-description.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ If the target is already known, use a direct tool — `read` for a known path, `
2626
- Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet").
2727
- Use thinking to control extended thinking level.
2828
- Use inherit_context if the agent needs the parent conversation history.
29-
- Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications). The worktree is automatically cleaned up if the agent makes no changes; otherwise the path and branch are returned in the result.{{scheduleGuideline}}
29+
- Use isolation: "worktree" only in an existing Git repository with a valid HEAD/at least one commit (safe parallel file modifications). Isolation uses the parent session cwd; a repository path mentioned only in the prompt cannot select another worktree base. Omit isolation for read-only work or a non-Git cwd. Never initialize or commit a repository solely to enable worktree isolation. The worktree is automatically cleaned up if the agent makes no changes; otherwise the path and branch are returned in the result.{{scheduleGuideline}}
3030

3131
## Writing the prompt
3232

src/agent-manager.ts

Lines changed: 121 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,56 @@
88
*/
99

1010
import { randomUUID } from "node:crypto";
11-
import { statSync } from "node:fs";
11+
import { statSync, unlinkSync } from "node:fs";
1212
import { isAbsolute } from "node:path";
1313
import type { Model } from "@earendil-works/pi-ai";
1414
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15+
import { buildAgentOutcome, sanitizeAgentCause } from "./agent-outcome.js";
1516
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
1617
import type { AgentInvocation, AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
1718
import { addUsage } from "./usage.js";
18-
import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
19+
import { cleanupWorktree, pruneWorktrees, tryCreateWorktree, type WorktreeCreateFailureReason } from "./worktree.js";
20+
21+
/** Model-facing guidance when worktree isolation cannot start. */
22+
function worktreeIsolationError(reason: WorktreeCreateFailureReason): Error {
23+
const retryWithoutIsolationOnce =
24+
"Retry the Agent call once without `isolation`; do not repeat the same call unchanged. " +
25+
"Do not initialize or commit a repository solely to enable worktree isolation.";
26+
const fixThenRetryIsolation =
27+
"Fix the worktree/Git problem, then retry the same Agent call with isolation: \"worktree\". " +
28+
"Do not drop isolation or fall back silently.";
29+
30+
if (reason === "not_git_repo" || reason === "no_head") {
31+
// Confirmed missing prerequisites — one safe corrective path is unisolated retry.
32+
return new Error(
33+
'Cannot run with isolation: "worktree" — requires an existing Git repository with a valid HEAD ' +
34+
"(at least one commit). " +
35+
retryWithoutIsolationOnce,
36+
);
37+
}
38+
39+
if (reason === "git_probe_failed") {
40+
// Indeterminate Git probe (timeout, missing git, permissions, safe.directory,
41+
// corrupt repo, malformed output) — never drop isolation.
42+
return new Error(
43+
'Cannot run with isolation: "worktree" — Git probe failed (worktree/Git infrastructure). ' +
44+
fixThenRetryIsolation,
45+
);
46+
}
47+
48+
if (reason === "repo_path_resolution_failed") {
49+
// Path/root resolution failed before `git worktree add` was attempted.
50+
return new Error(
51+
'Cannot run with isolation: "worktree" — failed to resolve the Git repository root/path for worktree creation. ' +
52+
fixThenRetryIsolation,
53+
);
54+
}
55+
56+
// Genuine `git worktree add` infrastructure failure — preserve isolation.
57+
return new Error(
58+
'Cannot run with isolation: "worktree" — `git worktree add` failed. ' + fixThenRetryIsolation,
59+
);
60+
}
1961

2062
export type OnAgentComplete = (record: AgentRecord) => void;
2163
export type OnAgentStart = (record: AgentRecord) => void;
@@ -240,13 +282,11 @@ export class AgentManager {
240282
// BEFORE state mutation so a throw doesn't leave the record half-running.
241283
let worktreeCwd: string | undefined;
242284
if (options.isolation === "worktree") {
243-
const wt = createWorktree(baseCwd, id);
244-
if (!wt) {
245-
throw new Error(
246-
'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
247-
'Initialize git and commit at least once, or omit `isolation`.',
248-
);
285+
const created = tryCreateWorktree(baseCwd, id);
286+
if (!created.ok) {
287+
throw worktreeIsolationError(created.reason);
249288
}
289+
const wt = created.worktree;
250290
record.worktree = wt;
251291
// workPath preserves subdirectory scoping for caller-supplied cwds: a
252292
// cwd deep in a monorepo maps to the same subdir inside the copy, not
@@ -261,17 +301,24 @@ export class AgentManager {
261301
record.status = "running";
262302
record.startedAt = Date.now();
263303
if (occupiesPoolSlot(record)) this.runningBackground++;
264-
this.onStart?.(record);
265304

266-
// Wire parent abort signal to stop the subagent when the parent is interrupted
305+
// Wire parent cancellation before invoking the runner. addEventListener()
306+
// does not replay an abort that already happened, so check synchronously and
307+
// pass the resulting already-aborted child signal into runAgent.
267308
let detachParentSignal: (() => void) | undefined;
268309
if (options.signal) {
269-
const onParentAbort = () => this.abort(id);
270-
options.signal.addEventListener("abort", onParentAbort, { once: true });
271-
detachParentSignal = () => options.signal!.removeEventListener("abort", onParentAbort);
310+
const onParentAbort = () => this.stop(id, "caller");
311+
if (options.signal.aborted) {
312+
onParentAbort();
313+
} else {
314+
options.signal.addEventListener("abort", onParentAbort, { once: true });
315+
detachParentSignal = () => options.signal!.removeEventListener("abort", onParentAbort);
316+
}
272317
}
273318
const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
274319

320+
if (options.isBackground) this.onStart?.(record);
321+
275322
const promise = runAgent(ctx, type, prompt, {
276323
pi,
277324
agentId: id,
@@ -311,6 +358,7 @@ export class AgentManager {
311358
},
312359
onSessionCreated: (session) => {
313360
record.session = session;
361+
if (!options.isBackground) this.onStart?.(record);
314362
// Flush any steers that arrived before the session was ready
315363
if (record.pendingSteers?.length) {
316364
for (const msg of record.pendingSteers) {
@@ -331,14 +379,15 @@ export class AgentManager {
331379
record.status = "aborted";
332380
} else if (failure) {
333381
record.status = "error";
334-
record.error = failure;
382+
record.error = sanitizeAgentCause(failure);
335383
} else {
336384
record.status = steered ? "steered" : "completed";
337385
}
338386
}
339387
record.result = responseText;
340388
record.session = session;
341389
record.completedAt ??= Date.now();
390+
record.outcome = buildAgentOutcome(record, "run");
342391

343392
detach();
344393

@@ -376,12 +425,17 @@ export class AgentManager {
376425
return responseText;
377426
})
378427
.catch((err) => {
379-
// Don't overwrite status if externally stopped via abort()
428+
const preSessionFailure = !record.session && record.status !== "stopped";
380429
if (record.status !== "stopped") {
381430
record.status = "error";
431+
record.error = sanitizeAgentCause(err);
382432
}
383-
record.error = err instanceof Error ? err.message : String(err);
384433
record.completedAt ??= Date.now();
434+
record.outcome = buildAgentOutcome(
435+
record,
436+
preSessionFailure ? "startup" : "run",
437+
record.status === "stopped" ? undefined : preSessionFailure ? "startup" : "provider",
438+
);
385439

386440
detach();
387441

@@ -401,14 +455,19 @@ export class AgentManager {
401455

402456
this.abortOwnedChildren(id);
403457

458+
// Foreground pre-session rejection is still pre-acceptance. Suppress all
459+
// completion side effects; spawnAndWait removes the record and transcript
460+
// before surfacing the tool error. Background callers already received an ID.
461+
if (!options.isBackground && preSessionFailure) return "";
462+
404463
// Fire onComplete for foreground agents too — lifecycle symmetry.
405464
// Mark resultConsumed so the callback skips notifications (result returned inline).
406465
if (!options.isBackground) {
407466
record.resultConsumed = true;
408-
this.onComplete?.(record);
467+
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
409468
} else {
410469
if (occupiesPoolSlot(record)) this.runningBackground--;
411-
this.onComplete?.(record);
470+
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
412471
this.drainQueue();
413472
}
414473
return "";
@@ -446,8 +505,9 @@ export class AgentManager {
446505
// Late failure (e.g. strict worktree-isolation) — surface on the record
447506
// so the user/agent can see it via /agents, then keep draining.
448507
record.status = "error";
449-
record.error = err instanceof Error ? err.message : String(err);
508+
record.error = sanitizeAgentCause(err);
450509
record.completedAt = Date.now();
510+
record.outcome = buildAgentOutcome(record, "startup", "startup");
451511
this.onComplete?.(record);
452512
}
453513
}
@@ -490,6 +550,18 @@ export class AgentManager {
490550
}
491551
const record = this.agents.get(id)!;
492552
await record.promise;
553+
// A foreground invocation is accepted only once the child session exists.
554+
// Async setup rejection before that boundary is still a tool invocation
555+
// failure: retain no record or ID for the caller to recover.
556+
if (record.status === "error" && !record.session) {
557+
const cause = record.error ?? "Agent startup failed.";
558+
if (record.outputFile) {
559+
try { unlinkSync(record.outputFile); } catch { /* absent/unwritable transcript */ }
560+
record.outputFile = undefined;
561+
}
562+
this.removeRecord(id, record);
563+
throw new Error(cause);
564+
}
493565
return { id, record };
494566
}
495567

@@ -509,9 +581,10 @@ export class AgentManager {
509581
record.completedAt = undefined;
510582
record.result = undefined;
511583
record.error = undefined;
584+
record.outcome = undefined;
512585

513586
try {
514-
const { text, failure } = await resumeAgent(record.session, prompt, {
587+
const { text, failure, aborted } = await resumeAgent(record.session, prompt, {
515588
onToolActivity: (activity) => {
516589
if (activity.type === "end") record.toolUses++;
517590
},
@@ -525,15 +598,24 @@ export class AgentManager {
525598
signal,
526599
});
527600
// Same contract as the spawn path (#144): a failed final turn is an
528-
// error, not a completion — but the resumed text stays available.
529-
record.status = failure ? "error" : "completed";
530-
if (failure) record.error = failure;
601+
// error, not a completion — but the resumed text stays available. A
602+
// caller-cancelled continuation is not a provider failure and offers no
603+
// recovery instruction.
604+
record.status = aborted ? "stopped" : failure ? "error" : "completed";
605+
if (failure) record.error = sanitizeAgentCause(failure);
606+
if (aborted) record.stopOrigin = "caller";
531607
record.result = text;
532608
record.completedAt = Date.now();
609+
record.outcome = buildAgentOutcome(
610+
record,
611+
"resume",
612+
aborted ? "caller_stop" : failure ? "provider" : "completed",
613+
);
533614
} catch (err) {
534615
record.status = "error";
535-
record.error = err instanceof Error ? err.message : String(err);
616+
record.error = sanitizeAgentCause(err);
536617
record.completedAt = Date.now();
618+
record.outcome = buildAgentOutcome(record, "resume", "provider");
537619
}
538620

539621
// Same contract as the spawn settle paths: children spawned during the
@@ -575,21 +657,25 @@ export class AgentManager {
575657
}
576658

577659
abort(id: string): boolean {
660+
return this.stop(id, "user");
661+
}
662+
663+
private stop(id: string, origin: "user" | "caller"): boolean {
578664
const record = this.agents.get(id);
579665
if (!record) return false;
580666

581-
// Remove from queue if queued
582667
if (record.status === "queued") {
583668
this.queue = this.queue.filter(q => q.id !== id);
584-
record.status = "stopped";
585-
record.completedAt = Date.now();
586-
return true;
669+
} else if (record.status === "running") {
670+
record.abortController?.abort();
671+
} else {
672+
return false;
587673
}
588674

589-
if (record.status !== "running") return false;
590-
record.abortController?.abort();
591675
record.status = "stopped";
676+
record.stopOrigin = origin;
592677
record.completedAt = Date.now();
678+
record.outcome = buildAgentOutcome(record, "run", origin === "caller" ? "caller_stop" : "user_stop");
593679
return true;
594680
}
595681

@@ -604,6 +690,7 @@ export class AgentManager {
604690
const cutoff = Date.now() - 10 * 60_000;
605691
for (const [id, record] of this.agents) {
606692
if (record.status === "running" || record.status === "queued") continue;
693+
if (record.isBackground === false && record.session) continue;
607694
if ((record.completedAt ?? 0) >= cutoff) continue;
608695
this.removeRecord(id, record);
609696
}
@@ -638,7 +725,9 @@ export class AgentManager {
638725
const record = this.agents.get(queued.id);
639726
if (record) {
640727
record.status = "stopped";
728+
record.stopOrigin = "caller";
641729
record.completedAt = Date.now();
730+
record.outcome = buildAgentOutcome(record, "run", "caller_stop");
642731
count++;
643732
}
644733
}
@@ -648,7 +737,9 @@ export class AgentManager {
648737
if (record.status === "running") {
649738
record.abortController?.abort();
650739
record.status = "stopped";
740+
record.stopOrigin = "caller";
651741
record.completedAt = Date.now();
742+
record.outcome = buildAgentOutcome(record, "run", "caller_stop");
652743
count++;
653744
}
654745
}

0 commit comments

Comments
 (0)