pi-pier 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -12
- package/package.json +3 -3
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +10 -13
- package/src/compact-coordinator.ts +279 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +77 -33
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +286 -486
- package/src/efficiency-store.ts +16 -29
- package/src/gc-core.ts +10 -36
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +202 -233
- package/src/history-store.ts +38 -23
- package/src/index-gates.ts +118 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +31 -29
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +31 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +16 -6
- package/src/index-worker.ts +8 -18
- package/src/index.ts +561 -570
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +138 -90
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/panel-open.ts +47 -0
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +53 -50
- package/src/pipe-channel.ts +44 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +766 -0
- package/src/{core → plugins}/terminal.ts +198 -139
- package/src/{core → plugins}/todo.ts +73 -134
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +26 -64
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +108 -84
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +25 -31
- package/src/role-state.ts +119 -0
- package/src/roles/worker-default.json +3 -3
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +61 -22
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +423 -120
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +383 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +577 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +187 -134
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +46 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/tree-view.ts +162 -0
- package/src/vocab.ts +52 -25
- package/src/worker-marker.ts +76 -0
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/focus-poller.ts +0 -244
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- package/src/subagent-session-io.ts +0 -230
package/src/subagent-spawn.ts
CHANGED
|
@@ -1,58 +1,284 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Why: spawn mixed herdr layout I/O with the subagent tool body. Placement
|
|
5
|
-
* races (D26 mutex) and D86 worktree grouping belong in one adapter.
|
|
2
|
+
* Subagent spawn domain: git/worktree I/O, tab placement, launch line, readiness wait, and the `spawn`
|
|
3
|
+
* action; placement is serialized by a mutex (concurrent spawns raced on tab lookup).
|
|
6
4
|
*/
|
|
5
|
+
import { execFile as nodeExecFile } from 'node:child_process';
|
|
6
|
+
import { randomUUID } from 'node:crypto';
|
|
7
|
+
import { mkdirSync, stat as statCb } from 'node:fs';
|
|
8
|
+
import { basename, dirname, join, resolve as pathResolve } from 'node:path';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
7
10
|
import type { HerdrClientLike } from './herdr-client.ts';
|
|
8
|
-
import {
|
|
11
|
+
import { delegationEnv } from './delegation-core.ts';
|
|
12
|
+
import { bareSessionId } from './session-tail.ts';
|
|
13
|
+
import { pipeNameCandidates, pipeNameFor, pipeRequest, pipeRequestTo } from './pipe-channel.ts';
|
|
14
|
+
import { composeForRole } from './manifest-compose.ts';
|
|
15
|
+
import { platformPaths } from './platform-paths.ts';
|
|
16
|
+
import { planSpawnProfileRow, type RoutingTelemetryRecord } from './routing-telemetry.ts';
|
|
9
17
|
import { runtimePolicy } from './runtime-policy.ts';
|
|
18
|
+
import { toolError } from './tool-error.ts';
|
|
19
|
+
import type { HistoryEntry } from './history-store.ts';
|
|
10
20
|
import {
|
|
11
|
-
|
|
12
|
-
READY_TAIL_CHARS,
|
|
13
|
-
planReadyAttempt,
|
|
14
|
-
readyFailureText,
|
|
15
|
-
type ReadyFailure,
|
|
16
|
-
} from './subagent-ready.ts';
|
|
17
|
-
import {
|
|
21
|
+
FOREGROUND_POLL_MS,
|
|
18
22
|
Semaphore,
|
|
23
|
+
allocateSubSlug,
|
|
24
|
+
buildAliveNotice,
|
|
25
|
+
buildBlockedGateNotice,
|
|
26
|
+
buildIsolatePreamble,
|
|
19
27
|
buildLaunchLine,
|
|
20
28
|
buildLaunchParts,
|
|
21
29
|
classifyWorktreeZone,
|
|
30
|
+
formatSubagentResult,
|
|
31
|
+
formatWorktreeStat,
|
|
32
|
+
isAlive,
|
|
22
33
|
isPathUnder,
|
|
34
|
+
makeProgressUpdate,
|
|
35
|
+
planForegroundTick,
|
|
36
|
+
planIsolateWorktree,
|
|
37
|
+
planLaunchValidation,
|
|
38
|
+
planShellReady,
|
|
23
39
|
planTabPlacement,
|
|
40
|
+
sleep,
|
|
41
|
+
subRef,
|
|
42
|
+
type SubEntry,
|
|
24
43
|
type TabPlacementPlan,
|
|
25
44
|
type WorktreeZone,
|
|
26
45
|
} from './subagent-core.ts';
|
|
27
|
-
import { parseShapeTree, pickGridSplit, type PaneCell } from './
|
|
28
|
-
import { fromShapeTree, planSpawnSplitRatio, type LayoutNode } from './
|
|
29
|
-
import type {
|
|
46
|
+
import { parseShapeTree, pickGridSplit, type PaneCell } from './plugins/grid-shape.ts';
|
|
47
|
+
import { fromShapeTree, planSpawnSplitRatio, type LayoutNode } from './plugins/heat-plan.ts';
|
|
48
|
+
import type { Poller } from './subagent-poller.ts';
|
|
49
|
+
import type { SessionIo } from './subagent-session.ts';
|
|
50
|
+
|
|
51
|
+
const statAsync = promisify(statCb);
|
|
52
|
+
|
|
53
|
+
/** Bound on waiting for a fresh pane's shell to reach its prompt. On timeout the launch line is sent
|
|
54
|
+
* anyway: `waitSubReady` still diagnoses a failed boot, and an unsupported probe must not stall spawn. */
|
|
55
|
+
export const SHELL_READY_TIMEOUT_MS = 2_000;
|
|
56
|
+
const SHELL_READY_POLL_MS = 100;
|
|
57
|
+
|
|
58
|
+
/* ── git adapter ────────────────────────────────────────────────── */
|
|
59
|
+
|
|
60
|
+
export interface GitResult {
|
|
61
|
+
readonly stdout: string;
|
|
62
|
+
readonly stderr: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface GitError extends Error {
|
|
66
|
+
readonly code?: string | number;
|
|
67
|
+
readonly stdout?: string;
|
|
68
|
+
readonly stderr?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type GitExecFile = (
|
|
72
|
+
file: string,
|
|
73
|
+
args: readonly string[],
|
|
74
|
+
options: { timeout: number; encoding: 'utf8'; maxBuffer: number },
|
|
75
|
+
) => Promise<{ stdout: string; stderr: string }>;
|
|
76
|
+
|
|
77
|
+
/** Output ceiling for one git call: a large checkout blows past execFile's 1MB default (ENOBUFS). */
|
|
78
|
+
const GIT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
79
|
+
|
|
80
|
+
export interface GitAdapter {
|
|
81
|
+
listWorktrees(cwd: string): Promise<GitResult>;
|
|
82
|
+
status(cwd: string): Promise<GitResult>;
|
|
83
|
+
/** Throws a GitError on failure/timeout; callers wanting "null on error" catch at the call site. */
|
|
84
|
+
run(cwd: string, args: readonly string[]): Promise<GitResult>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const execFile = promisify(nodeExecFile);
|
|
88
|
+
|
|
89
|
+
export class NodeGitAdapter implements GitAdapter {
|
|
90
|
+
private readonly gitExecutable: string;
|
|
91
|
+
private readonly timeoutMs: number;
|
|
92
|
+
private readonly exec: GitExecFile;
|
|
93
|
+
|
|
94
|
+
constructor(
|
|
95
|
+
gitExecutable: string = 'git',
|
|
96
|
+
timeoutMs: number = runtimePolicy.gitTimeoutMs,
|
|
97
|
+
exec: GitExecFile = execFile as unknown as GitExecFile,
|
|
98
|
+
) {
|
|
99
|
+
this.gitExecutable = gitExecutable;
|
|
100
|
+
this.timeoutMs = timeoutMs;
|
|
101
|
+
this.exec = exec;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
listWorktrees(cwd: string): Promise<GitResult> {
|
|
105
|
+
return this.run(cwd, ['worktree', 'list', '--porcelain']);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
status(cwd: string): Promise<GitResult> {
|
|
109
|
+
return this.run(cwd, ['status', '--short']);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async run(cwd: string, args: readonly string[]): Promise<GitResult> {
|
|
113
|
+
try {
|
|
114
|
+
const { stdout, stderr } = await this.exec(
|
|
115
|
+
this.gitExecutable,
|
|
116
|
+
['-C', cwd, ...args],
|
|
117
|
+
{ timeout: this.timeoutMs, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER },
|
|
118
|
+
);
|
|
119
|
+
return { stdout, stderr };
|
|
120
|
+
} catch (err) {
|
|
121
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
122
|
+
throw Object.assign(err instanceof Error ? err : new Error(), {
|
|
123
|
+
message: `Git ${args[0] ?? 'command'} failed: ${message}`,
|
|
124
|
+
}) as GitError;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const defaultGitAdapter = new NodeGitAdapter();
|
|
130
|
+
|
|
131
|
+
/* ── git I/O used by spawn, isolate, GC and settlement stat lines ── */
|
|
132
|
+
|
|
133
|
+
const WORKTREES_CACHE_MS = 5000;
|
|
134
|
+
|
|
135
|
+
export interface GitIo {
|
|
136
|
+
listWorktrees(cwd: string): Promise<string[]>;
|
|
137
|
+
runGit(cwd: string, args: string[]): Promise<string | null>;
|
|
138
|
+
worktreeStatLine(entry: SubEntry): Promise<string | null>;
|
|
139
|
+
invalidateWorktreesCache(): void;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createGitIo(git: GitAdapter = defaultGitAdapter): GitIo {
|
|
143
|
+
let worktreesCache: { at: number; list: string[] } | null = null;
|
|
144
|
+
|
|
145
|
+
async function listWorktrees(cwd: string): Promise<string[]> {
|
|
146
|
+
if (worktreesCache && Date.now() - worktreesCache.at < WORKTREES_CACHE_MS) return worktreesCache.list;
|
|
147
|
+
let list: string[] = [];
|
|
148
|
+
try {
|
|
149
|
+
const { stdout } = await git.listWorktrees(cwd);
|
|
150
|
+
for (const line of String(stdout).split('\n')) {
|
|
151
|
+
const m = /^worktree (.+)$/.exec(line.trim());
|
|
152
|
+
if (m) list.push(m[1]!);
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
list = [];
|
|
156
|
+
}
|
|
157
|
+
worktreesCache = { at: Date.now(), list };
|
|
158
|
+
return list;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function runGit(cwd: string, args: string[]): Promise<string | null> {
|
|
162
|
+
try {
|
|
163
|
+
const { stdout } = await git.run(cwd, args);
|
|
164
|
+
return stdout == null ? null : String(stdout);
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function lastStatLine(out: string | null): string | null {
|
|
171
|
+
if (!out) return null;
|
|
172
|
+
const lines = out.replace(/\r/g, '').split('\n').map((l) => l.trim()).filter(Boolean);
|
|
173
|
+
return lines.length ? lines[lines.length - 1]! : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function worktreeStatLine(entry: SubEntry): Promise<string | null> {
|
|
177
|
+
const porcelain = await runGit(entry.cwd, ['status', '--porcelain']);
|
|
178
|
+
if (porcelain === null) return null;
|
|
179
|
+
const dirtyCount = porcelain.split('\n').filter((l) => l.trim() !== '').length;
|
|
180
|
+
if (entry.isolate) {
|
|
181
|
+
const statOut = await runGit(entry.cwd, ['diff', '--stat', `${entry.isolate.baseSha}...HEAD`]);
|
|
182
|
+
const commitsOut = await runGit(entry.cwd, ['rev-list', '--count', `${entry.isolate.baseSha}..HEAD`]);
|
|
183
|
+
const commits = commitsOut != null && /^\d+$/.test(commitsOut.trim()) ? Number(commitsOut.trim()) : null;
|
|
184
|
+
return formatWorktreeStat({ branch: entry.isolate.branch, commits, statLine: lastStatLine(statOut), dirtyCount });
|
|
185
|
+
}
|
|
186
|
+
return formatWorktreeStat({
|
|
187
|
+
branch: null,
|
|
188
|
+
commits: null,
|
|
189
|
+
statLine: lastStatLine(await runGit(entry.cwd, ['diff', '--stat', 'HEAD'])),
|
|
190
|
+
dirtyCount,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
listWorktrees,
|
|
196
|
+
runGit,
|
|
197
|
+
worktreeStatLine,
|
|
198
|
+
invalidateWorktreesCache: () => { worktreesCache = null; },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/* ── readiness wait (pure planners) ─────────────────────────────── */
|
|
203
|
+
|
|
204
|
+
const READY_BASE_INTERVAL_MS = 500;
|
|
205
|
+
const READY_MAX_INTERVAL_MS = 4000;
|
|
206
|
+
/** Liveness/agent sampling is far more expensive than a ping, so it runs on its own cadence. */
|
|
207
|
+
const READY_LIVENESS_SAMPLE_MS = 2500;
|
|
208
|
+
/** Enough of the pane tail to carry a stack trace head into the failure text. */
|
|
209
|
+
const READY_TAIL_CHARS = 1200;
|
|
210
|
+
|
|
211
|
+
interface ReadyFailure {
|
|
212
|
+
readonly paneId: string;
|
|
213
|
+
readonly reason: 'pane-gone' | 'timeout';
|
|
214
|
+
readonly elapsedMs: number;
|
|
215
|
+
readonly timeoutMs: number;
|
|
216
|
+
readonly lastStatus?: string | null;
|
|
217
|
+
readonly tail?: string | null;
|
|
218
|
+
readonly hint?: string | null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
type ReadyAttemptPlan =
|
|
222
|
+
| { readonly kind: 'ready' }
|
|
223
|
+
| { readonly kind: 'retry'; readonly delayMs: number }
|
|
224
|
+
| { readonly kind: 'give-up'; readonly reason: 'pane-gone' | 'timeout' };
|
|
30
225
|
|
|
31
|
-
|
|
226
|
+
/** Decide one readiness attempt. `alive` is tri-state on purpose: an unavailable liveness probe
|
|
227
|
+
* (`null`) must not be read as death, or a slow herdr socket fails spawns that are merely slow. */
|
|
228
|
+
export function planReadyAttempt(opts: {
|
|
229
|
+
elapsedMs: number;
|
|
230
|
+
attempt: number;
|
|
231
|
+
timeoutMs: number;
|
|
232
|
+
alive: boolean | null;
|
|
233
|
+
ready: boolean;
|
|
234
|
+
}): ReadyAttemptPlan {
|
|
235
|
+
if (opts.ready) return { kind: 'ready' };
|
|
236
|
+
if (opts.alive === false) return { kind: 'give-up', reason: 'pane-gone' };
|
|
237
|
+
if (opts.elapsedMs >= opts.timeoutMs) return { kind: 'give-up', reason: 'timeout' };
|
|
238
|
+
return { kind: 'retry', delayMs: readyBackoffMs(opts.attempt) };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Exponential backoff with a cap: 500ms, 1s, 2s, 4s, 4s, … */
|
|
242
|
+
export function readyBackoffMs(attempt: number, baseMs = READY_BASE_INTERVAL_MS, capMs = READY_MAX_INTERVAL_MS): number {
|
|
243
|
+
const n = Math.max(0, Math.floor(attempt));
|
|
244
|
+
return Math.min(capMs, baseMs * 2 ** n);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Failure text for the model; the three cases stay distinguishable: pane died (the tail carries the
|
|
248
|
+
* reason), pane alive but never opened its pipe, pane alive and just slower than the timeout. */
|
|
249
|
+
export function readyFailureText(failure: ReadyFailure): string {
|
|
250
|
+
const seconds = Math.round(failure.elapsedMs / 1000);
|
|
251
|
+
const head = failure.reason === 'pane-gone'
|
|
252
|
+
? `subagent pane ${failure.paneId} exited before its pipe became ready (waited ${seconds}s)`
|
|
253
|
+
: `subagent pane ${failure.paneId} pipe not ready within ${seconds}s (limit ${Math.round(failure.timeoutMs / 1000)}s)`;
|
|
254
|
+
const why = failure.reason === 'pane-gone'
|
|
255
|
+
? 'The child process is gone — its last output below usually carries the reason.'
|
|
256
|
+
: failure.lastStatus === 'working'
|
|
257
|
+
? 'The pane is alive and working, so the prompt may simply be slow to boot; retrying the same call is safe.'
|
|
258
|
+
: 'The pane is alive but never registered its pipe — the child pi process may still be starting, or it failed to load the pier extension.';
|
|
259
|
+
const tail = failure.tail ? `\nlast output of ${failure.paneId}:\n${failure.tail}` : '';
|
|
260
|
+
const hint = failure.hint ? `\n${failure.hint}` : '';
|
|
261
|
+
return `${head}\n${why}${hint}${tail}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* ── spawner ────────────────────────────────────────────────────── */
|
|
265
|
+
|
|
266
|
+
interface SpawnEnv {
|
|
32
267
|
paneId: string;
|
|
33
268
|
tabId: string;
|
|
34
269
|
workspaceId: string;
|
|
35
270
|
}
|
|
36
271
|
|
|
37
|
-
|
|
38
|
-
nodePath: string;
|
|
39
|
-
cliPath: string;
|
|
40
|
-
extPath: string;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface SpawnerHost {
|
|
272
|
+
interface SpawnerHost {
|
|
44
273
|
client: HerdrClientLike;
|
|
45
274
|
env: SpawnEnv | null;
|
|
46
|
-
runtime:
|
|
275
|
+
runtime: { nodePath: string; cliPath: string; extPath: string };
|
|
47
276
|
git: GitIo;
|
|
48
277
|
/** Test override. Production reads `runtimePolicy.readinessTimeoutMs`. */
|
|
49
278
|
readinessTimeoutMs?: number;
|
|
50
279
|
}
|
|
51
280
|
|
|
52
|
-
|
|
53
|
-
export type ReadyOutcome =
|
|
54
|
-
| { ok: true }
|
|
55
|
-
| { ok: false; failure: ReadyFailure; message: string };
|
|
281
|
+
type ReadyOutcome = { ok: true } | { ok: false; message: string };
|
|
56
282
|
|
|
57
283
|
export interface Spawner {
|
|
58
284
|
spawnPaneInTaskTab(
|
|
@@ -64,7 +290,6 @@ export interface Spawner {
|
|
|
64
290
|
launchLine(resumeFile?: string | null, roleModel?: string | null, approve?: boolean): string;
|
|
65
291
|
approveFor(cwd: string, masterCwd: string): Promise<boolean>;
|
|
66
292
|
waitSubReady(cwd: string, paneId: string): Promise<ReadyOutcome>;
|
|
67
|
-
liveTabs(): Promise<Array<{ tabName: string; tabId: string }>>;
|
|
68
293
|
findExistingPane(sessionFile: string | null): Promise<{ paneId: string; tabId: string } | null>;
|
|
69
294
|
}
|
|
70
295
|
|
|
@@ -87,17 +312,26 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
87
312
|
return buildLaunchLine(buildLaunchParts(h.runtime, { resumeFile, roleModel, approve }));
|
|
88
313
|
}
|
|
89
314
|
|
|
315
|
+
/** herdr 0.9.4 refuses over-long commands on a shell that is still starting; `pane.send_text`
|
|
316
|
+
* has no such guard. Poll the foreground process until the shell owns the PTY (or give up). */
|
|
317
|
+
async function waitShellReady(paneId: string): Promise<void> {
|
|
318
|
+
const deadline = Date.now() + SHELL_READY_TIMEOUT_MS;
|
|
319
|
+
while (Date.now() < deadline) {
|
|
320
|
+
const info = await h.client.processInfo(paneId).catch(() => null);
|
|
321
|
+
if (info === null) return; // older server or pane already gone: keep the old best-effort path
|
|
322
|
+
if (planShellReady(info.foreground) === 'ready') return;
|
|
323
|
+
await sleep(SHELL_READY_POLL_MS);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
90
327
|
async function approveFor(cwd: string, masterCwd: string): Promise<boolean> {
|
|
91
328
|
if (isPathUnder(cwd, masterCwd)) return true;
|
|
92
329
|
const zone = classifyWorktreeZone({ cwd, masterCwd, worktrees: await h.git.listWorktrees(masterCwd) });
|
|
93
330
|
return zone.zone === 'worktree';
|
|
94
331
|
}
|
|
95
332
|
|
|
96
|
-
/**
|
|
97
|
-
*
|
|
98
|
-
* itself is gone from pane.list. Screen tail is sampled while the pane exists
|
|
99
|
-
* so a crash that leaves a shell still explains itself on timeout.
|
|
100
|
-
*/
|
|
333
|
+
/** Wait for the child's pipe with backoff; give up early when the pane is gone. The screen tail
|
|
334
|
+
* is sampled while the pane exists so a crash that leaves a shell still explains itself. */
|
|
101
335
|
async function waitSubReady(cwd: string, paneId: string): Promise<ReadyOutcome> {
|
|
102
336
|
const names = pipeNameCandidates(cwd, paneId);
|
|
103
337
|
const startedAt = Date.now();
|
|
@@ -110,7 +344,6 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
110
344
|
while (true) {
|
|
111
345
|
const now = Date.now();
|
|
112
346
|
const elapsedMs = now - startedAt;
|
|
113
|
-
// Liveness/agent sampling is much more expensive than a ping, so it runs on its own cadence.
|
|
114
347
|
if (now - lastProbeAt >= READY_LIVENESS_SAMPLE_MS || attempt === 0) {
|
|
115
348
|
lastProbeAt = now;
|
|
116
349
|
const probe = await probeChildPane(paneId);
|
|
@@ -136,7 +369,7 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
136
369
|
if (parts.length > 0) explainHint = `Herdr detection diagnosis: ${parts.join('; ')}`;
|
|
137
370
|
}
|
|
138
371
|
} catch {
|
|
139
|
-
/*
|
|
372
|
+
/* diagnostics never mask the root error */
|
|
140
373
|
}
|
|
141
374
|
}
|
|
142
375
|
const defaultHint = plan.reason === 'timeout' && lastStatus === 'working'
|
|
@@ -152,10 +385,10 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
152
385
|
tail,
|
|
153
386
|
hint,
|
|
154
387
|
};
|
|
155
|
-
return { ok: false,
|
|
388
|
+
return { ok: false, message: readyFailureText(failure) };
|
|
156
389
|
}
|
|
157
390
|
attempt += 1;
|
|
158
|
-
await
|
|
391
|
+
await sleep(plan.delayMs);
|
|
159
392
|
}
|
|
160
393
|
}
|
|
161
394
|
|
|
@@ -165,26 +398,22 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
165
398
|
try {
|
|
166
399
|
const res = await pipeRequest(name, { type: 'ping', id: `ping-${Date.now()}` }, 3000);
|
|
167
400
|
if (res.type === 'ok') return true;
|
|
168
|
-
} catch {
|
|
169
|
-
/* not ready on this name */
|
|
170
|
-
}
|
|
401
|
+
} catch { /* not ready on this name */ }
|
|
171
402
|
}
|
|
172
403
|
return false;
|
|
173
404
|
}
|
|
174
405
|
|
|
175
|
-
/** Pane liveness + last visible output; socket failures degrade to "unknown"
|
|
406
|
+
/** Pane liveness + last visible output; socket failures degrade to "unknown", never "dead". */
|
|
176
407
|
async function probeChildPane(paneId: string): Promise<{ alive: boolean | null; status: string | null; tail: string | null }> {
|
|
177
408
|
let alive: boolean | null = null;
|
|
178
409
|
let status: string | null = null;
|
|
179
410
|
try {
|
|
180
|
-
// pane.list includes unknown shells; agent.list does not
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
const pane = panes.find((p) => p.paneId === paneId);
|
|
411
|
+
// pane.list includes unknown shells; agent.list does not. A fresh split is absent from
|
|
412
|
+
// agent.list until pi is identified — that is boot, not death.
|
|
413
|
+
const pane = (await h.client.listPanes()).find((p) => p.paneId === paneId);
|
|
184
414
|
alive = pane != null;
|
|
185
415
|
status = pane?.agentStatus ?? null;
|
|
186
416
|
} catch {
|
|
187
|
-
/* keep null: an unreachable socket must not be read as a dead child */
|
|
188
417
|
}
|
|
189
418
|
let tail: string | null = null;
|
|
190
419
|
try {
|
|
@@ -205,8 +434,7 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
205
434
|
const release = await tabMutex.acquire();
|
|
206
435
|
try {
|
|
207
436
|
const allPanes = await h.client.listPanes();
|
|
208
|
-
const mainTabId = allPanes.find((p) => p.paneId === h.env?.paneId)?.tabId
|
|
209
|
-
?? (h.env?.tabId ? h.env.tabId : null);
|
|
437
|
+
const mainTabId = allPanes.find((p) => p.paneId === h.env?.paneId)?.tabId ?? (h.env?.tabId ? h.env.tabId : null);
|
|
210
438
|
let plan: TabPlacementPlan = planTabPlacement({
|
|
211
439
|
desiredTab: placement.desiredTab,
|
|
212
440
|
description: placement.description,
|
|
@@ -265,6 +493,7 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
265
493
|
focus: false,
|
|
266
494
|
...(ratio != null ? { ratio } : {}),
|
|
267
495
|
});
|
|
496
|
+
await waitShellReady(paneId);
|
|
268
497
|
await h.client.sendPaneText(paneId, launch);
|
|
269
498
|
return { tabId: plan.tabId, paneId, tabName: plan.tabName };
|
|
270
499
|
}
|
|
@@ -276,6 +505,7 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
276
505
|
cwd,
|
|
277
506
|
env: envOver,
|
|
278
507
|
});
|
|
508
|
+
await waitShellReady(created.paneId);
|
|
279
509
|
await h.client.sendPaneText(created.paneId, launch);
|
|
280
510
|
return { tabId: created.tabId, paneId: created.paneId, tabName: plan.tabName };
|
|
281
511
|
} finally {
|
|
@@ -283,19 +513,314 @@ export function createSpawner(h: SpawnerHost): Spawner {
|
|
|
283
513
|
}
|
|
284
514
|
}
|
|
285
515
|
|
|
516
|
+
/** D94: reuse a pane that already runs the same session instead of competing on one transcript. */
|
|
286
517
|
async function findExistingPane(sessionFile: string | null): Promise<{ paneId: string; tabId: string } | null> {
|
|
287
518
|
if (!sessionFile) return null;
|
|
288
519
|
try {
|
|
289
520
|
const agents = await h.client.listAgents();
|
|
290
521
|
const match = agents.find((a) => a.session === sessionFile && a.status !== 'unknown');
|
|
291
522
|
if (!match) return null;
|
|
292
|
-
const
|
|
293
|
-
const pane = panes.find((p) => p.paneId === match.paneId);
|
|
523
|
+
const pane = (await h.client.listPanes()).find((p) => p.paneId === match.paneId);
|
|
294
524
|
return pane ? { paneId: match.paneId, tabId: pane.tabId } : null;
|
|
295
525
|
} catch {
|
|
296
526
|
return null;
|
|
297
527
|
}
|
|
298
528
|
}
|
|
299
529
|
|
|
300
|
-
return { spawnPaneInTaskTab, launchLine, approveFor, waitSubReady,
|
|
530
|
+
return { spawnPaneInTaskTab, launchLine, approveFor, waitSubReady, findExistingPane };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/* ── spawn action ───────────────────────────────────────────────── */
|
|
534
|
+
|
|
535
|
+
interface SpawnActionHost {
|
|
536
|
+
client: HerdrClientLike;
|
|
537
|
+
env: SpawnEnv | null;
|
|
538
|
+
subSemaphore: Semaphore;
|
|
539
|
+
subs: Map<string, SubEntry>;
|
|
540
|
+
persistSubs(): void;
|
|
541
|
+
writeHistory(entry: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void;
|
|
542
|
+
pendingIsolateBranches: Set<string>;
|
|
543
|
+
lastMachineInjectAt: Map<string, number>;
|
|
544
|
+
lastRequestIdByPane: Map<string, number | string>;
|
|
545
|
+
git: GitIo;
|
|
546
|
+
session: SessionIo;
|
|
547
|
+
spawn: Spawner;
|
|
548
|
+
poller: Poller;
|
|
549
|
+
/** Master session id for the delegation identity handed to the worker. */
|
|
550
|
+
getSessionId: () => string;
|
|
551
|
+
/** Phase 0 routing telemetry (RFC rfc-jev-role-routing §8): best-effort spawn profile; absent in tests. */
|
|
552
|
+
logRouting?: (row: RoutingTelemetryRecord) => void;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function createSpawnAction(h: SpawnActionHost) {
|
|
556
|
+
const { client, env, git, subs } = h;
|
|
557
|
+
const { resolveSessionFile, collectFinalText, readAskFlag, probeAlive, subSessionState } = h.session;
|
|
558
|
+
const { spawnPaneInTaskTab, launchLine, waitSubReady } = h.spawn;
|
|
559
|
+
const { startPoller } = h.poller;
|
|
560
|
+
|
|
561
|
+
return async function executeSubagentSpawn(
|
|
562
|
+
params: Record<string, unknown> | undefined,
|
|
563
|
+
toolCtx: unknown,
|
|
564
|
+
onUpdate?: (update: unknown) => void,
|
|
565
|
+
): Promise<unknown> {
|
|
566
|
+
const launch = planLaunchValidation(params, client.available);
|
|
567
|
+
if (launch.kind === 'error') return toolError(launch.text);
|
|
568
|
+
const { spec, background, isolate, cwdParam, roleKind: kind, suggested, manifestRole, tab } = launch;
|
|
569
|
+
const masterCwd = (toolCtx as { cwd?: string })?.cwd ?? process.cwd();
|
|
570
|
+
const taskId = randomUUID();
|
|
571
|
+
// Validate the role before any isolate worktree exists: this early return has no cleanup path.
|
|
572
|
+
let roleManifestEnv: Record<string, string> = {};
|
|
573
|
+
let roleModel: string | null = null;
|
|
574
|
+
// Delegation identity (RFC rfc-delegation-identity §2.1): the worker learns who delegated it.
|
|
575
|
+
let delegationEnvPatch: Record<string, string> = {};
|
|
576
|
+
try {
|
|
577
|
+
const { role, manifest } = composeForRole(manifestRole ?? 'worker-default', suggested, { loadRoleOpts: { baseDir: masterCwd } });
|
|
578
|
+
roleManifestEnv = {
|
|
579
|
+
PI_HERDR_ROLE_MANIFEST: JSON.stringify({
|
|
580
|
+
role: role.role,
|
|
581
|
+
version: role.version,
|
|
582
|
+
tools: manifest.tools,
|
|
583
|
+
permissions: manifest.permissions,
|
|
584
|
+
unknownTools: manifest.unknownTools,
|
|
585
|
+
services: role.services ?? {},
|
|
586
|
+
// Without guidelines parseRuntimeManifest yields none and the pier-role prompt section silently no-ops.
|
|
587
|
+
...(role.guidelines?.length ? { guidelines: role.guidelines } : {}),
|
|
588
|
+
}),
|
|
589
|
+
// Role base: /pier-role and the pipe switch resolve .pi-herdr/roles/ against the MASTER's checkout.
|
|
590
|
+
PI_HERDR_ROLE_BASE: masterCwd,
|
|
591
|
+
};
|
|
592
|
+
delegationEnvPatch = delegationEnv({
|
|
593
|
+
parentPaneId: env?.paneId ?? '',
|
|
594
|
+
parentSessionId: bareSessionId(h.getSessionId()),
|
|
595
|
+
// Phase 2: the master's own pipe so the worker's report_progress can dial home (D49).
|
|
596
|
+
parentPipe: pipeNameFor(masterCwd, env?.paneId ?? ''),
|
|
597
|
+
taskId,
|
|
598
|
+
description: spec.description,
|
|
599
|
+
role: role.role,
|
|
600
|
+
});
|
|
601
|
+
if (typeof role.model === 'string' && role.model.trim()) roleModel = role.model.trim();
|
|
602
|
+
h.logRouting?.(
|
|
603
|
+
planSpawnProfileRow({
|
|
604
|
+
now: Date.now(),
|
|
605
|
+
roleExplicit: typeof manifestRole === 'string' && manifestRole.trim() !== '',
|
|
606
|
+
role: role.role,
|
|
607
|
+
allowedTools: suggested,
|
|
608
|
+
manifestTools: manifest.tools,
|
|
609
|
+
task: spec.prompt,
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
} catch (err) {
|
|
613
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
614
|
+
return toolError(`Error: role "${manifestRole}" manifest invalid — ${msg}`);
|
|
615
|
+
}
|
|
616
|
+
// D98: create the isolate through pier's execFile git path, not herdr's socket worktree.create
|
|
617
|
+
// (bootstrap races, missing root-pane env, linked_worktree_source rejection).
|
|
618
|
+
let isolateMeta: SubEntry['isolate'] | null = null;
|
|
619
|
+
let cwd = masterCwd;
|
|
620
|
+
if (isolate) {
|
|
621
|
+
const baseSha = await git.runGit(masterCwd, ['rev-parse', 'HEAD']);
|
|
622
|
+
if (baseSha == null) return toolError('Error: isolate requires a git repository with at least one commit');
|
|
623
|
+
const sha = baseSha.trim();
|
|
624
|
+
const pierBranches = new Set(
|
|
625
|
+
((await git.runGit(masterCwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/pier/'])) ?? '')
|
|
626
|
+
.split('\n').filter(Boolean),
|
|
627
|
+
);
|
|
628
|
+
const repoName = basename((await git.listWorktrees(masterCwd))[0] ?? masterCwd);
|
|
629
|
+
const plan = planIsolateWorktree({
|
|
630
|
+
description: spec.description,
|
|
631
|
+
taskHex: taskId.slice(0, 6),
|
|
632
|
+
existingPierBranches: pierBranches,
|
|
633
|
+
});
|
|
634
|
+
const wtPath = join(platformPaths.worktreeBaseDir, repoName, plan.worktreeDirName);
|
|
635
|
+
try {
|
|
636
|
+
// git worktree add does not create its parent directory, so a first run would fail.
|
|
637
|
+
mkdirSync(dirname(wtPath), { recursive: true });
|
|
638
|
+
} catch { /* existing directory or permissions failure is handled by worktree add */ }
|
|
639
|
+
// Keep the branch pending between worktree add and subs.set: the readiness wait lets the
|
|
640
|
+
// ticker scan, which could otherwise reclaim a new clean =HEAD-ancestor worktree as an orphan.
|
|
641
|
+
h.pendingIsolateBranches.add(plan.branch);
|
|
642
|
+
const added = await git.runGit(masterCwd, ['worktree', 'add', '-b', plan.branch, wtPath, sha]);
|
|
643
|
+
if (added === null) {
|
|
644
|
+
h.pendingIsolateBranches.delete(plan.branch);
|
|
645
|
+
return toolError(`Error: failed to create worktree ${wtPath} (branch ${plan.branch}) — run \`git worktree prune\` and retry if it reports stale entries`);
|
|
646
|
+
}
|
|
647
|
+
git.invalidateWorktreesCache(); // so the zone classifier sees the new worktree immediately
|
|
648
|
+
cwd = wtPath;
|
|
649
|
+
isolateMeta = { worktreePath: wtPath, branch: plan.branch, baseSha: sha, releasedAt: null, retainNotified: false };
|
|
650
|
+
spec.prompt = `${buildIsolatePreamble({ worktreePath: wtPath, branch: plan.branch, baseShort: sha.slice(0, 7) })}\n\n${spec.prompt}`;
|
|
651
|
+
} else if (cwdParam) {
|
|
652
|
+
cwd = pathResolve(masterCwd, cwdParam);
|
|
653
|
+
try {
|
|
654
|
+
if (!(await statAsync(cwd)).isDirectory()) throw new Error('not a directory');
|
|
655
|
+
} catch {
|
|
656
|
+
return toolError(`Error: \`cwd\` is not an existing directory: ${cwdParam}`);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// D86 R1: group by the git worktree containing cwd (main checkout → main tab; other → its tab).
|
|
660
|
+
const zone = classifyWorktreeZone({ cwd, masterCwd, worktrees: await git.listWorktrees(masterCwd) });
|
|
661
|
+
// D86 trust: pass -a only for the master's checkout/worktrees; external dirs stay behind pi's Trust dialog.
|
|
662
|
+
const approve = isPathUnder(cwd, masterCwd) || zone.zone === 'worktree';
|
|
663
|
+
|
|
664
|
+
const release = await h.subSemaphore.acquire();
|
|
665
|
+
let paneId = '';
|
|
666
|
+
try {
|
|
667
|
+
const spawnedAt = Date.now();
|
|
668
|
+
const spawned = await spawnPaneInTaskTab(
|
|
669
|
+
{ desiredTab: tab, description: spec.description, zone },
|
|
670
|
+
cwd,
|
|
671
|
+
// pier owns worker state reporting (report_agent with todo/blocked badges); pi 1.1+'s own
|
|
672
|
+
// OSC 7501 program-status emission would become a second, competing source once a terminal
|
|
673
|
+
// or dashboard starts reading it. One reporter per pane.
|
|
674
|
+
{ PI_HERDR_SUBAGENT: '1', PI_PROGRAM_STATUS: '0', ...roleManifestEnv, ...delegationEnvPatch },
|
|
675
|
+
launchLine(null, roleModel, approve),
|
|
676
|
+
);
|
|
677
|
+
paneId = spawned.paneId;
|
|
678
|
+
// Phase 2: allocate once per spawn; revives/history keep the row's slug (stability beats beauty).
|
|
679
|
+
const slug = allocateSubSlug(
|
|
680
|
+
spec.description,
|
|
681
|
+
taskId,
|
|
682
|
+
new Set([...subs.values()].map((s) => s.slug ?? '').filter(Boolean)),
|
|
683
|
+
);
|
|
684
|
+
const entry: SubEntry = {
|
|
685
|
+
taskId,
|
|
686
|
+
kind,
|
|
687
|
+
paneId,
|
|
688
|
+
slug,
|
|
689
|
+
tabId: spawned.tabId,
|
|
690
|
+
tabName: spawned.tabName,
|
|
691
|
+
cwd,
|
|
692
|
+
description: spec.description,
|
|
693
|
+
background,
|
|
694
|
+
status: 'running',
|
|
695
|
+
sessionFile: null,
|
|
696
|
+
launchCommand: [launchLine(null, roleModel, approve)],
|
|
697
|
+
createdAt: Date.now(),
|
|
698
|
+
revivedFrom: null,
|
|
699
|
+
...(isolateMeta ? { isolate: isolateMeta } : {}),
|
|
700
|
+
};
|
|
701
|
+
const ready = await waitSubReady(cwd, paneId);
|
|
702
|
+
if (!ready.ok) throw new Error(ready.message);
|
|
703
|
+
// herdr's per-pane report lags right after spawn, so the mtime fallback would otherwise adopt
|
|
704
|
+
// the newest PRE-EXISTING session in cwd; only a file written after the pane existed counts.
|
|
705
|
+
entry.sessionFile = await resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: spawnedAt - 2_000 });
|
|
706
|
+
subs.set(paneId, entry);
|
|
707
|
+
h.persistSubs();
|
|
708
|
+
onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
|
|
709
|
+
// M11: inject through the extension pipe, reserving the PTY keyboard channel for the human;
|
|
710
|
+
// injection and injectTs must stay together or the spawn leaves a ghost running ledger entry.
|
|
711
|
+
const injectTs = Date.now();
|
|
712
|
+
const injected = await pipeRequestTo(cwd, paneId, {
|
|
713
|
+
type: 'prompt',
|
|
714
|
+
id: `prompt-${taskId}`,
|
|
715
|
+
text: spec.prompt,
|
|
716
|
+
// The pipe name MUST be scoped by the MASTER's cwd: the server binds pipeNameFor(master
|
|
717
|
+
// session cwd, own paneId); the worker's cwd names a pipe nobody listens on.
|
|
718
|
+
from: pipeNameFor(masterCwd, env?.paneId ?? ''),
|
|
719
|
+
push: background,
|
|
720
|
+
});
|
|
721
|
+
if (injected.type !== 'ok') {
|
|
722
|
+
throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
|
|
723
|
+
}
|
|
724
|
+
h.lastMachineInjectAt.set(paneId, injectTs); // attribute working state during the observation window
|
|
725
|
+
const handToPoller = (requestId: string): void => {
|
|
726
|
+
entry.background = true;
|
|
727
|
+
h.lastRequestIdByPane.set(paneId, requestId);
|
|
728
|
+
void startPoller(paneId, cwd, injectTs, spec.description, requestId);
|
|
729
|
+
h.persistSubs();
|
|
730
|
+
h.writeHistory(entry, undefined, 'to-background');
|
|
731
|
+
};
|
|
732
|
+
|
|
733
|
+
if (background) {
|
|
734
|
+
handToPoller(`prompt-${taskId}`);
|
|
735
|
+
return {
|
|
736
|
+
content: [{ type: 'text', text: `started subagent ${subRef(entry)} (task ${taskId})` }],
|
|
737
|
+
details: { paneId, taskId, background: true, role: kind, slug },
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Foreground waiting: content gate + patience threshold before backgrounding an idle pane.
|
|
742
|
+
const patienceDeadline = Date.now() + runtimePolicy.foregroundPatienceMs;
|
|
743
|
+
let text: string | null = null;
|
|
744
|
+
let settledKind: 'settled' | 'timeout' = 'timeout';
|
|
745
|
+
while (Date.now() < Math.min(patienceDeadline, spawnedAt + runtimePolicy.subagentTimeoutMs)) {
|
|
746
|
+
const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
|
|
747
|
+
const session = (state === 'idle' || state === 'done')
|
|
748
|
+
? await subSessionState(paneId, cwd, injectTs)
|
|
749
|
+
: { text: null, pendingTool: false, activity: false };
|
|
750
|
+
const tick = planForegroundTick({ state, session });
|
|
751
|
+
if (tick.kind === 'blocked') {
|
|
752
|
+
const question = await readAskFlag(paneId);
|
|
753
|
+
handToPoller(`prompt-${taskId}`);
|
|
754
|
+
return {
|
|
755
|
+
content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
|
|
756
|
+
details: { paneId, taskId, background: true, blocked: true, role: kind },
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
if (tick.kind === 'settled') {
|
|
760
|
+
text = tick.text;
|
|
761
|
+
settledKind = 'settled';
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
if (tick.kind === 'wait') {
|
|
765
|
+
await sleep(tick.delayMs);
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
if (tick.kind === 'collect-final') {
|
|
769
|
+
text = await collectFinalText(paneId, cwd, injectTs, 6);
|
|
770
|
+
if (text) { settledKind = 'settled'; break; }
|
|
771
|
+
await sleep(FOREGROUND_POLL_MS);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (!text) {
|
|
775
|
+
const probe = await probeAlive(paneId, cwd);
|
|
776
|
+
if (isAlive(probe, Date.now())) {
|
|
777
|
+
handToPoller(`prompt-${taskId}`);
|
|
778
|
+
return {
|
|
779
|
+
content: [{ type: 'text', text: buildAliveNotice({ paneId, description: spec.description, scenario: 'moved-to-bg', probe }, Date.now()) }],
|
|
780
|
+
details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
settledKind = 'timeout';
|
|
784
|
+
}
|
|
785
|
+
const outcome =
|
|
786
|
+
settledKind === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
|
|
787
|
+
: text ? { kind: 'completed' as const, text }
|
|
788
|
+
: { kind: 'no-output' as const, text: '' };
|
|
789
|
+
entry.status = 'consumed';
|
|
790
|
+
entry.consumedAt = Date.now();
|
|
791
|
+
h.persistSubs();
|
|
792
|
+
h.writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
|
|
793
|
+
return {
|
|
794
|
+
content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
|
|
795
|
+
details: { paneId, taskId, background, role: kind },
|
|
796
|
+
};
|
|
797
|
+
} catch (err) {
|
|
798
|
+
// A mid-spawn failure must remove the ledger entry and close its pane, or a ghost entry never settles.
|
|
799
|
+
if (paneId) {
|
|
800
|
+
subs.delete(paneId);
|
|
801
|
+
h.persistSubs();
|
|
802
|
+
void client.closePane(paneId).catch(() => { /* GC covers board exceptions */ });
|
|
803
|
+
}
|
|
804
|
+
// D98: an isolate whose startup never became ready has no work to preserve.
|
|
805
|
+
if (isolateMeta) {
|
|
806
|
+
const { branch, worktreePath } = isolateMeta;
|
|
807
|
+
void git.runGit(masterCwd, ['worktree', 'remove', '--force', worktreePath])
|
|
808
|
+
.then(() => git.runGit(masterCwd, ['branch', '-D', branch]))
|
|
809
|
+
.catch(() => { /* best effort only */ });
|
|
810
|
+
}
|
|
811
|
+
return {
|
|
812
|
+
content: [{
|
|
813
|
+
type: 'text',
|
|
814
|
+
text: formatSubagentResult(
|
|
815
|
+
{ kind: 'spawn-failed', text: String((err as Error)?.message ?? err) },
|
|
816
|
+
spec.description,
|
|
817
|
+
),
|
|
818
|
+
}],
|
|
819
|
+
details: {},
|
|
820
|
+
};
|
|
821
|
+
} finally {
|
|
822
|
+
release();
|
|
823
|
+
if (isolateMeta) h.pendingIsolateBranches.delete(isolateMeta.branch);
|
|
824
|
+
}
|
|
825
|
+
};
|
|
301
826
|
}
|