pi-pier 0.2.6 → 0.3.0
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 +20 -7
- package/package.json +7 -2
- package/schemas/efficiency-config.schema.json +77 -0
- package/skills/pi-herdr-workflow/SKILL.md +13 -1
- package/src/ansi-text.ts +83 -0
- package/src/ask-multi.ts +260 -0
- package/src/ask-user.ts +458 -0
- package/src/bootstrap.ts +5 -2
- package/src/compact-coordinator.ts +454 -0
- package/src/compact-economics-core.ts +396 -0
- package/src/config-catalog-core.ts +452 -0
- package/src/config-command.ts +181 -0
- package/src/config-guide.ts +361 -0
- package/src/core/grid-shape.ts +4 -2
- package/src/core/heat-plan.ts +160 -0
- package/src/core/observation.ts +555 -0
- package/src/core/subagent.ts +187 -393
- package/src/core/terminal.ts +270 -47
- package/src/core/todo.ts +49 -14
- package/src/dashboard-command.ts +89 -0
- package/src/efficiency-config-core.ts +661 -0
- package/src/efficiency-config-io.ts +53 -0
- package/src/efficiency-store.ts +312 -0
- package/src/focus-poller.ts +244 -0
- package/src/gc-core.ts +85 -0
- package/src/git-adapter.ts +12 -3
- package/src/herdr-client.ts +292 -36
- package/src/index-locks.ts +27 -9
- package/src/index-master.ts +5 -0
- package/src/index-notices.ts +13 -2
- package/src/index-pipe.ts +5 -0
- package/src/index-worker.ts +5 -0
- package/src/index.ts +360 -60
- package/src/jev-client.ts +146 -0
- package/src/jev-core.ts +381 -0
- package/src/lock-core.ts +105 -16
- package/src/observation-core.ts +248 -0
- package/src/pier-options.ts +96 -0
- package/src/pipe-channel.ts +52 -7
- package/src/reducer-core.ts +358 -0
- package/src/reducer-invoker.ts +392 -0
- package/src/renderers.ts +198 -0
- package/src/role-loader.ts +2 -1
- package/src/role-manifest.ts +0 -4
- package/src/runtime-policy.ts +5 -8
- package/src/session-tail.ts +60 -5
- package/src/settle-wake-core.ts +3 -1
- package/src/slim-frame.ts +60 -3
- package/src/storage-layout.ts +23 -0
- package/src/subagent-core.ts +3 -1
- package/src/subagent-execute.ts +328 -0
- package/src/subagent-gc.ts +33 -13
- package/src/subagent-launch.ts +4 -0
- package/src/subagent-list-action.ts +69 -0
- package/src/subagent-output-action.ts +104 -0
- package/src/subagent-output-adapter.ts +27 -0
- package/src/subagent-output-core.ts +263 -0
- package/src/subagent-poll-loop.ts +59 -39
- package/src/subagent-poller.ts +46 -0
- package/src/subagent-ready.ts +89 -0
- package/src/subagent-registry.ts +70 -0
- package/src/subagent-resolution.ts +41 -0
- package/src/subagent-scope.ts +3 -1
- package/src/subagent-session-io.ts +115 -32
- package/src/subagent-spawn.ts +153 -8
- package/src/swallow.ts +75 -0
- package/src/terminal-core.ts +161 -10
- package/src/todo-core.ts +5 -4
- package/src/todo-reminder-core.ts +8 -1
- package/src/todos-service.ts +18 -6
- package/src/tool-error.ts +26 -0
- package/src/vocab.ts +7 -4
|
@@ -8,22 +8,25 @@ import { appendFileSync } from 'node:fs';
|
|
|
8
8
|
import type { Context } from '@deepseek-ai/cordis';
|
|
9
9
|
import type { HerdrClientLike, HerdrAgentState } from './herdr-client.ts';
|
|
10
10
|
import { applyReportedSessionFile } from './history-store.ts';
|
|
11
|
-
import { runtimePolicy } from './runtime-policy.ts';
|
|
11
|
+
import { runtimePolicy, type RuntimePolicy } from './runtime-policy.ts';
|
|
12
12
|
import { mountSubagentScope } from './subagent-scope.ts';
|
|
13
13
|
import {
|
|
14
14
|
OBSERVATION_TICK_MS,
|
|
15
15
|
TAKEOVER_RECHECK_MS,
|
|
16
|
+
buildSettlementNoticeText,
|
|
17
|
+
formatObservationTimeoutNotice,
|
|
18
|
+
formatPaneClosedNotice,
|
|
19
|
+
isSettlementCandidate,
|
|
16
20
|
planBlockedGate,
|
|
17
21
|
planObservationTick,
|
|
18
22
|
planTakeoverTick,
|
|
19
23
|
planVacuumTick,
|
|
20
24
|
} from './subagent-poller.ts';
|
|
21
25
|
import { buildBlockedGateNotice, type SubEntry } from './subagent-core.ts';
|
|
22
|
-
import { formatSettlementNotice } from './vocab.ts';
|
|
23
26
|
import type { SessionIo } from './subagent-session-io.ts';
|
|
24
27
|
import type { GitIo } from './subagent-git-io.ts';
|
|
25
28
|
|
|
26
|
-
function sleep(ms: number): Promise<void> {
|
|
29
|
+
export function sleep(ms: number): Promise<void> {
|
|
27
30
|
const wait = Promise.withResolvers<void>();
|
|
28
31
|
setTimeout(wait.resolve, ms);
|
|
29
32
|
return wait.promise;
|
|
@@ -43,20 +46,29 @@ export interface PollerHost {
|
|
|
43
46
|
reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[];
|
|
44
47
|
withReconcileNotes(base: string, notes: readonly string[]): string;
|
|
45
48
|
claimSettleNotice(key: string): boolean;
|
|
49
|
+
/** Optional sleep seam for timing control without real delays */
|
|
50
|
+
sleep?: (ms: number) => Promise<void>;
|
|
51
|
+
/** Optional clock seam for deterministic virtual timestamps */
|
|
52
|
+
now?: () => number;
|
|
53
|
+
/** Optional runtime policy overrides for testing */
|
|
54
|
+
policy?: Partial<RuntimePolicy>;
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
export interface Poller {
|
|
49
|
-
startPoller(paneId: string, cwd: string, spawnedAt: number, injectTs: number, description: string, requestId: string): void
|
|
58
|
+
startPoller(paneId: string, cwd: string, spawnedAt: number, injectTs: number, description: string, requestId: string): Promise<void>;
|
|
50
59
|
readonly pollers: Set<string>;
|
|
51
60
|
}
|
|
52
61
|
|
|
53
62
|
export function createPoller(h: PollerHost): Poller {
|
|
54
63
|
const pollers = new Set<string>();
|
|
55
64
|
const subScopes = new Map<string, { dispose: () => Promise<void> }>();
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
const
|
|
65
|
+
const doSleep = h.sleep ?? sleep;
|
|
66
|
+
const now = h.now ?? Date.now;
|
|
67
|
+
const observeWindowMs = h.policy?.observationWindowMs ?? runtimePolicy.observationWindowMs;
|
|
68
|
+
const machineInjectGraceMs = h.policy?.settlementWindowMs ?? runtimePolicy.settlementWindowMs;
|
|
69
|
+
const takeoverIdleMs = h.policy?.settlementWindowMs ?? runtimePolicy.settlementWindowMs;
|
|
70
|
+
const timeoutMs = h.policy?.subagentTimeoutMs ?? runtimePolicy.subagentTimeoutMs;
|
|
71
|
+
const pollIntervalMs = h.policy?.pollIntervalMs ?? runtimePolicy.pollIntervalMs;
|
|
60
72
|
|
|
61
73
|
async function pollLoop(
|
|
62
74
|
paneId: string,
|
|
@@ -67,10 +79,10 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
67
79
|
requestId: string,
|
|
68
80
|
): Promise<void> {
|
|
69
81
|
void spawnedAt;
|
|
70
|
-
const startedAt =
|
|
71
|
-
let lastActivityAt =
|
|
82
|
+
const startedAt = now();
|
|
83
|
+
let lastActivityAt = now();
|
|
72
84
|
const pollTrace = process.env.PI_HERDR_TRACE
|
|
73
|
-
? (msg: string) => { try { appendFileSync(process.env.PI_HERDR_TRACE!, `d98poll ${
|
|
85
|
+
? (msg: string) => { try { appendFileSync(process.env.PI_HERDR_TRACE!, `d98poll ${now()} ${paneId} ${msg}\n`); } catch { /* best-effort */ } }
|
|
74
86
|
: null;
|
|
75
87
|
try {
|
|
76
88
|
while (true) {
|
|
@@ -85,11 +97,11 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
85
97
|
currentStatus: agent?.status ?? null,
|
|
86
98
|
previousStatus: entry.lastAgentStatus,
|
|
87
99
|
idleStartedAt: entry.observationStartedAt,
|
|
88
|
-
now:
|
|
100
|
+
now: now(),
|
|
89
101
|
idleMs: takeoverIdleMs,
|
|
90
102
|
});
|
|
91
103
|
if (tick.kind === 'start-idle') {
|
|
92
|
-
entry.observationStartedAt =
|
|
104
|
+
entry.observationStartedAt = now();
|
|
93
105
|
entry.lastAgentStatus = 'idle';
|
|
94
106
|
h.persistSubs();
|
|
95
107
|
} else if (tick.kind === 'return-control') {
|
|
@@ -106,14 +118,14 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
106
118
|
/* status probe failure must not stop the poller */
|
|
107
119
|
}
|
|
108
120
|
if (entry.userTakeover) {
|
|
109
|
-
await
|
|
121
|
+
await doSleep(TAKEOVER_RECHECK_MS);
|
|
110
122
|
continue;
|
|
111
123
|
}
|
|
112
124
|
}
|
|
113
125
|
|
|
114
126
|
let state: HerdrAgentState | null;
|
|
115
127
|
try {
|
|
116
|
-
state = await h.client.waitAgent(paneId, ['idle', 'done', 'blocked'],
|
|
128
|
+
state = await h.client.waitAgent(paneId, ['idle', 'done', 'blocked'], pollIntervalMs);
|
|
117
129
|
} catch {
|
|
118
130
|
state = null;
|
|
119
131
|
}
|
|
@@ -133,22 +145,22 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
133
145
|
if (gate.kind === 'clear-gate') h.blockedGateNotified.delete(paneId);
|
|
134
146
|
if (state === 'idle' || state === 'done') {
|
|
135
147
|
const s = await h.session.subSessionState(paneId, cwd, injectTs);
|
|
136
|
-
pollTrace?.(`state=${state} text=${s.text ? s.text.length : 'null'} pend=${s.pendingTool} act=${s.activity} obs=${String(entry.observationStartedAt ?? null)} takeover=${String(entry.userTakeover
|
|
137
|
-
if (
|
|
148
|
+
pollTrace?.(`state=${state} text=${s.text ? s.text.length : 'null'} pend=${s.pendingTool} act=${s.activity} obs=${String(entry.observationStartedAt ?? null)} takeover=${String(Boolean(entry.userTakeover))}`);
|
|
149
|
+
if (isSettlementCandidate(s)) {
|
|
138
150
|
const closing = s.text;
|
|
139
151
|
const obs = planObservationTick({
|
|
140
152
|
observationStartedAt: entry.observationStartedAt,
|
|
141
|
-
now:
|
|
153
|
+
now: now(),
|
|
142
154
|
windowMs: observeWindowMs,
|
|
143
155
|
agentStatus: null,
|
|
144
|
-
machineInjectAgoMs:
|
|
156
|
+
machineInjectAgoMs: now() - (h.lastMachineInjectAt.get(paneId) ?? 0),
|
|
145
157
|
machineInjectGraceMs,
|
|
146
158
|
});
|
|
147
159
|
if (obs.kind === 'start-observation') {
|
|
148
|
-
entry.observationStartedAt =
|
|
160
|
+
entry.observationStartedAt = now();
|
|
149
161
|
entry.lastAgentStatus = 'idle';
|
|
150
162
|
h.persistSubs();
|
|
151
|
-
await
|
|
163
|
+
await doSleep(OBSERVATION_TICK_MS);
|
|
152
164
|
continue;
|
|
153
165
|
}
|
|
154
166
|
let agentStatus: string | null = null;
|
|
@@ -160,27 +172,27 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
160
172
|
}
|
|
161
173
|
const obs2 = planObservationTick({
|
|
162
174
|
observationStartedAt: entry.observationStartedAt,
|
|
163
|
-
now:
|
|
175
|
+
now: now(),
|
|
164
176
|
windowMs: observeWindowMs,
|
|
165
177
|
agentStatus,
|
|
166
|
-
machineInjectAgoMs:
|
|
178
|
+
machineInjectAgoMs: now() - (h.lastMachineInjectAt.get(paneId) ?? 0),
|
|
167
179
|
machineInjectGraceMs,
|
|
168
180
|
});
|
|
169
181
|
if (obs2.kind === 'user-takeover') {
|
|
170
182
|
entry.userTakeover = true;
|
|
171
|
-
entry.observationStartedAt =
|
|
183
|
+
entry.observationStartedAt = now();
|
|
172
184
|
entry.lastAgentStatus = 'working';
|
|
173
185
|
h.persistSubs();
|
|
174
186
|
continue;
|
|
175
187
|
}
|
|
176
188
|
if (obs2.kind === 'machine-inject-reset') {
|
|
177
|
-
entry.observationStartedAt =
|
|
189
|
+
entry.observationStartedAt = now();
|
|
178
190
|
h.persistSubs();
|
|
179
|
-
await
|
|
191
|
+
await doSleep(OBSERVATION_TICK_MS);
|
|
180
192
|
continue;
|
|
181
193
|
}
|
|
182
194
|
if (obs2.kind === 'wait') {
|
|
183
|
-
await
|
|
195
|
+
await doSleep(OBSERVATION_TICK_MS);
|
|
184
196
|
continue;
|
|
185
197
|
}
|
|
186
198
|
entry.observationStartedAt = null;
|
|
@@ -191,12 +203,15 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
191
203
|
);
|
|
192
204
|
}
|
|
193
205
|
entry.status = 'consumed';
|
|
194
|
-
entry.consumedAt =
|
|
206
|
+
entry.consumedAt = now();
|
|
207
|
+
// A3: this path consumed the sub too - persist, or the session snapshot keeps it
|
|
208
|
+
// "running" and a restart replays a ghost subagent into list/settle-wake/zombie sweep.
|
|
209
|
+
h.persistSubs();
|
|
195
210
|
h.writeHistory(entry, { outcome: closing }, 'poll-settle');
|
|
196
211
|
const notes = h.reconcileOnSettlement(description, 'settled');
|
|
197
212
|
const statLine = await h.git.worktreeStatLine(entry);
|
|
198
213
|
const notice = h.withReconcileNotes(
|
|
199
|
-
|
|
214
|
+
buildSettlementNoticeText(`${paneId} (${description})`, closing, statLine),
|
|
200
215
|
notes,
|
|
201
216
|
);
|
|
202
217
|
if (h.claimSettleNotice(`${paneId}:${requestId}`)) {
|
|
@@ -211,26 +226,26 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
211
226
|
}
|
|
212
227
|
let alive = false;
|
|
213
228
|
try {
|
|
214
|
-
alive = (await h.client.
|
|
229
|
+
alive = (await h.client.listPanes()).some((p) => p.paneId === paneId);
|
|
215
230
|
} catch {
|
|
216
231
|
alive = true;
|
|
217
232
|
}
|
|
218
233
|
const vacuum = planVacuumTick({
|
|
219
234
|
waitState: state,
|
|
220
235
|
paneAlive: alive,
|
|
221
|
-
now:
|
|
236
|
+
now: now(),
|
|
222
237
|
lastActivityAt,
|
|
223
238
|
timeoutMs,
|
|
224
239
|
});
|
|
225
|
-
if (vacuum.refreshActivity) lastActivityAt =
|
|
240
|
+
if (vacuum.refreshActivity) lastActivityAt = now();
|
|
226
241
|
if (vacuum.action === 'pane-closed') {
|
|
227
242
|
entry.status = 'consumed';
|
|
228
|
-
entry.consumedAt =
|
|
243
|
+
entry.consumedAt = now();
|
|
229
244
|
h.persistSubs();
|
|
230
245
|
h.writeHistory(entry, { outcome: 'pane closed before settling' }, 'poll-pane-closed');
|
|
231
246
|
const notes = h.reconcileOnSettlement(description, 'failed');
|
|
232
247
|
const notice = h.withReconcileNotes(
|
|
233
|
-
|
|
248
|
+
formatPaneClosedNotice(paneId, description),
|
|
234
249
|
notes,
|
|
235
250
|
);
|
|
236
251
|
try {
|
|
@@ -240,12 +255,17 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
240
255
|
}
|
|
241
256
|
if (vacuum.action === 'timeout') {
|
|
242
257
|
entry.status = 'consumed';
|
|
243
|
-
entry.consumedAt =
|
|
258
|
+
entry.consumedAt = now();
|
|
244
259
|
h.persistSubs();
|
|
245
260
|
h.writeHistory(entry, { outcome: 'observation timeout' }, 'poll-timeout');
|
|
246
261
|
const notes = h.reconcileOnSettlement(description, 'failed');
|
|
247
262
|
const notice = h.withReconcileNotes(
|
|
248
|
-
|
|
263
|
+
formatObservationTimeoutNotice({
|
|
264
|
+
paneId,
|
|
265
|
+
description,
|
|
266
|
+
idleSeconds: Math.round((now() - lastActivityAt) / 1000),
|
|
267
|
+
startedAtIso: new Date(startedAt).toISOString(),
|
|
268
|
+
}),
|
|
249
269
|
notes,
|
|
250
270
|
);
|
|
251
271
|
try {
|
|
@@ -259,10 +279,10 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
259
279
|
}
|
|
260
280
|
}
|
|
261
281
|
|
|
262
|
-
function startPoller(paneId: string, cwd: string, spawnedAt: number, injectTs: number, description: string, requestId: string): void {
|
|
263
|
-
if (pollers.has(paneId)) return;
|
|
282
|
+
function startPoller(paneId: string, cwd: string, spawnedAt: number, injectTs: number, description: string, requestId: string): Promise<void> {
|
|
283
|
+
if (pollers.has(paneId)) return Promise.resolve();
|
|
264
284
|
pollers.add(paneId);
|
|
265
|
-
|
|
285
|
+
return (async () => {
|
|
266
286
|
try {
|
|
267
287
|
if (!subScopes.has(paneId)) {
|
|
268
288
|
const fiber = await mountSubagentScope(h.sessionRoot, paneId, {
|
package/src/subagent-poller.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* window explicit and unit-testable without a live pane.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { formatSettlementNotice } from './vocab.ts';
|
|
10
|
+
|
|
9
11
|
export const TAKEOVER_RECHECK_MS = 5_000;
|
|
10
12
|
export const OBSERVATION_TICK_MS = 1_000;
|
|
11
13
|
|
|
@@ -88,3 +90,47 @@ export function planVacuumTick(input: {
|
|
|
88
90
|
if (input.now - lastActivityAt > input.timeoutMs) return { refreshActivity, action: 'timeout' };
|
|
89
91
|
return { refreshActivity, action: 'continue' };
|
|
90
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Determine whether subSessionState qualifies as an observation candidate.
|
|
96
|
+
*
|
|
97
|
+
* A16: closing text (only produced by a terminal assistant message) OR an ENDED turn settles.
|
|
98
|
+
* Merely having an assistant message — the state a worker is in between tool calls, while the next
|
|
99
|
+
* assistant message streams — must NOT qualify: on 2026-09-13 three live workers were announced as
|
|
100
|
+
* "finished … left no closing message" and one was closed mid-task by GC afterwards.
|
|
101
|
+
*/
|
|
102
|
+
export function isSettlementCandidate(input: {
|
|
103
|
+
text: string | null;
|
|
104
|
+
pendingTool: boolean;
|
|
105
|
+
activity: boolean;
|
|
106
|
+
turnEnded?: boolean;
|
|
107
|
+
}): boolean {
|
|
108
|
+
if (input.text) return true;
|
|
109
|
+
return !input.pendingTool && input.turnEnded === true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Formats the combined settlement notice with optional git worktree stat. */
|
|
113
|
+
export function buildSettlementNoticeText(
|
|
114
|
+
agentLabel: string,
|
|
115
|
+
closing: string | null,
|
|
116
|
+
statLine: string | null,
|
|
117
|
+
): string {
|
|
118
|
+
const base = formatSettlementNotice(agentLabel, closing);
|
|
119
|
+
return statLine ? `${base}\n${statLine}` : base;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Formats notice when background subagent pane was closed prematurely. */
|
|
123
|
+
export function formatPaneClosedNotice(paneId: string, description: string): string {
|
|
124
|
+
return `Background subagent ${paneId} (${description}) stopped before settling (its pane closed).`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Formats notice when background subagent exceeded observation timeout. */
|
|
128
|
+
export function formatObservationTimeoutNotice(input: {
|
|
129
|
+
paneId: string;
|
|
130
|
+
description: string;
|
|
131
|
+
idleSeconds: number;
|
|
132
|
+
startedAtIso: string;
|
|
133
|
+
}): string {
|
|
134
|
+
return `Background subagent ${input.paneId} (${input.description}) has shown no progress for ${input.idleSeconds}s (observed since ${input.startedAtIso}). Run subagent(action: "list") to check its live state; if it is working, let it run — its settlement notice will arrive automatically. Do not sleep-wait.`;
|
|
135
|
+
}
|
|
136
|
+
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A14: spawn readiness had a single 30 s ping window and reported failures as one bare line
|
|
3
|
+
* ("subagent pane wD:pJ pipe not ready within 30000ms"), even when the child process had already
|
|
4
|
+
* died with a stack trace on its screen. With 3–4 concurrent isolated workers (each running heavy
|
|
5
|
+
* commands) that window was hit repeatedly, and the diagnosis cost far more than the retry would.
|
|
6
|
+
*
|
|
7
|
+
* This module is the pure half: how long to wait, how to back off, when to give up early
|
|
8
|
+
* (the pane is gone — no point in waiting for a pipe that cannot appear) and how to word the
|
|
9
|
+
* failure so the model and the human can act on it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Ping cadence: quick first probes, then exponential backoff capped at `READY_MAX_INTERVAL_MS`. */
|
|
13
|
+
export const READY_BASE_INTERVAL_MS = 500;
|
|
14
|
+
export const READY_MAX_INTERVAL_MS = 4000;
|
|
15
|
+
|
|
16
|
+
/** Wait this long between two samples of the child pane's liveness (pane.list + pane read). */
|
|
17
|
+
export const READY_LIVENESS_SAMPLE_MS = 2500;
|
|
18
|
+
|
|
19
|
+
export interface ReadyProbe {
|
|
20
|
+
/** The pane still exists in pane.list (unknown shells count as alive). */
|
|
21
|
+
readonly paneAlive: boolean;
|
|
22
|
+
/** Tail of the pane's screen, used only to explain a failure. */
|
|
23
|
+
readonly tail?: string | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ReadyAttemptPlan =
|
|
27
|
+
| { readonly kind: 'ready' }
|
|
28
|
+
| { readonly kind: 'retry'; readonly delayMs: number }
|
|
29
|
+
| { readonly kind: 'give-up'; readonly reason: 'pane-gone' | 'timeout' };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Decide one readiness attempt.
|
|
33
|
+
* `alive` is tri-state on purpose: an unavailable liveness probe (`null`) must not be read as death,
|
|
34
|
+
* otherwise a slow herdr socket would fail spawns that are merely slow to boot.
|
|
35
|
+
*/
|
|
36
|
+
export function planReadyAttempt(opts: {
|
|
37
|
+
elapsedMs: number;
|
|
38
|
+
attempt: number;
|
|
39
|
+
timeoutMs: number;
|
|
40
|
+
alive: boolean | null;
|
|
41
|
+
ready: boolean;
|
|
42
|
+
}): ReadyAttemptPlan {
|
|
43
|
+
if (opts.ready) return { kind: 'ready' };
|
|
44
|
+
if (opts.alive === false) return { kind: 'give-up', reason: 'pane-gone' };
|
|
45
|
+
if (opts.elapsedMs >= opts.timeoutMs) return { kind: 'give-up', reason: 'timeout' };
|
|
46
|
+
return { kind: 'retry', delayMs: readyBackoffMs(opts.attempt) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Exponential backoff with a cap: 500ms, 1s, 2s, 4s, 4s, … */
|
|
50
|
+
export function readyBackoffMs(attempt: number, baseMs = READY_BASE_INTERVAL_MS, capMs = READY_MAX_INTERVAL_MS): number {
|
|
51
|
+
const n = Math.max(0, Math.floor(attempt));
|
|
52
|
+
return Math.min(capMs, baseMs * 2 ** n);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** How many characters of pane tail to attach to a failure (enough for a stack trace head). */
|
|
56
|
+
export const READY_TAIL_CHARS = 1200;
|
|
57
|
+
|
|
58
|
+
export interface ReadyFailure {
|
|
59
|
+
readonly paneId: string;
|
|
60
|
+
readonly reason: 'pane-gone' | 'timeout';
|
|
61
|
+
readonly elapsedMs: number;
|
|
62
|
+
readonly timeoutMs: number;
|
|
63
|
+
/** Some agent states the wait observed (working/idle/…), for the "agent never reported" case. */
|
|
64
|
+
readonly lastStatus?: string | null;
|
|
65
|
+
readonly tail?: string | null;
|
|
66
|
+
/** True when the child process exited (pane gone) rather than never starting to report. */
|
|
67
|
+
readonly hint?: string | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Failure text for the model. The three cases it must distinguish:
|
|
72
|
+
* 1. the pane died (usually the child crashed — the tail carries the reason, cf. A13's stack trace);
|
|
73
|
+
* 2. the pane is alive but never opened its pipe (extension failed to load, wrong pane env);
|
|
74
|
+
* 3. the pane is alive and its agent is working, just slower than the timeout (retry is safe).
|
|
75
|
+
*/
|
|
76
|
+
export function readyFailureText(failure: ReadyFailure): string {
|
|
77
|
+
const seconds = Math.round(failure.elapsedMs / 1000);
|
|
78
|
+
const head = failure.reason === 'pane-gone'
|
|
79
|
+
? `subagent pane ${failure.paneId} exited before its pipe became ready (waited ${seconds}s)`
|
|
80
|
+
: `subagent pane ${failure.paneId} pipe not ready within ${seconds}s (limit ${Math.round(failure.timeoutMs / 1000)}s)`;
|
|
81
|
+
const why = failure.reason === 'pane-gone'
|
|
82
|
+
? 'The child process is gone — its last output below usually carries the reason.'
|
|
83
|
+
: failure.lastStatus === 'working'
|
|
84
|
+
? 'The pane is alive and working, so the prompt may simply be slow to boot; retrying the same call is safe.'
|
|
85
|
+
: '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.';
|
|
86
|
+
const tail = failure.tail ? `\nlast output of ${failure.paneId}:\n${failure.tail}` : '';
|
|
87
|
+
const hint = failure.hint ? `\n${failure.hint}` : '';
|
|
88
|
+
return `${head}\n${why}${hint}${tail}`;
|
|
89
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { HerdrClientLike } from './herdr-client.ts';
|
|
2
|
+
import { foldSubsRegistry, makeRegistry, SUBS_CUSTOM_TYPE, type SubEntry } from './subagent-core.ts';
|
|
3
|
+
import { swallow } from './swallow.ts';
|
|
4
|
+
|
|
5
|
+
export interface SubagentRegistryHost {
|
|
6
|
+
pi: { appendEntry?: (customType: string, data: unknown) => void };
|
|
7
|
+
client: HerdrClientLike;
|
|
8
|
+
subs: Map<string, SubEntry>;
|
|
9
|
+
writeHistory: (entry: SubEntry, patch?: { status?: SubEntry['status']; closedAt?: number }, via?: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SubagentRegistry {
|
|
13
|
+
/** Append the registry snapshot to the session branch; duplicate snapshots are skipped (O1). */
|
|
14
|
+
persist(): void;
|
|
15
|
+
/** Replay `pi-herdr.subs` entries from the session branch into the live map. */
|
|
16
|
+
rebuild(eventCtx: unknown): void;
|
|
17
|
+
/** B6: close running rows whose pane herdr no longer lists. */
|
|
18
|
+
sweepZombieRunning(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Owns durable registry projection and startup recovery, separate from tool actions. */
|
|
22
|
+
export function createSubagentRegistry(host: SubagentRegistryHost): SubagentRegistry {
|
|
23
|
+
let lastSnapshot = '';
|
|
24
|
+
|
|
25
|
+
function persist(): void {
|
|
26
|
+
try {
|
|
27
|
+
const registry = makeRegistry([...host.subs.values()]);
|
|
28
|
+
const snapshot = JSON.stringify(registry);
|
|
29
|
+
if (snapshot === lastSnapshot) return;
|
|
30
|
+
lastSnapshot = snapshot;
|
|
31
|
+
host.pi.appendEntry?.(SUBS_CUSTOM_TYPE, registry);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
// Best-effort: session logging must not break delegation, but a ghost running subagent is the
|
|
34
|
+
// symptom when this fails silently, so keep the reason queryable (B9).
|
|
35
|
+
swallow('subagent.persist-subs', err);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function rebuild(eventCtx: unknown): void {
|
|
40
|
+
try {
|
|
41
|
+
const entries = (eventCtx as { sessionManager?: { getBranch?: () => readonly unknown[] } })
|
|
42
|
+
?.sessionManager?.getBranch?.() ?? [];
|
|
43
|
+
const registry = foldSubsRegistry(entries as Parameters<typeof foldSubsRegistry>[0]);
|
|
44
|
+
for (const entry of registry.subs) host.subs.set(entry.paneId, entry);
|
|
45
|
+
lastSnapshot = ''; // Branch replay replaced live state; force the next persist.
|
|
46
|
+
} catch {
|
|
47
|
+
/* Registry recovery failure must not block the live session. */
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function sweepZombieRunning(): Promise<void> {
|
|
52
|
+
if (!host.client.available || host.subs.size === 0) return;
|
|
53
|
+
let livePaneIds: ReadonlySet<string>;
|
|
54
|
+
try {
|
|
55
|
+
livePaneIds = new Set((await host.client.listPanes()).map((pane) => pane.paneId));
|
|
56
|
+
} catch {
|
|
57
|
+
return; // Do not close agents when the liveness lookup itself failed.
|
|
58
|
+
}
|
|
59
|
+
let changed = false;
|
|
60
|
+
for (const [paneId, entry] of host.subs) {
|
|
61
|
+
if (entry.status !== 'running' || livePaneIds.has(paneId)) continue;
|
|
62
|
+
entry.status = 'closed';
|
|
63
|
+
host.writeHistory(entry, { status: 'closed', closedAt: Date.now() }, 'zombie-sweep');
|
|
64
|
+
changed = true;
|
|
65
|
+
}
|
|
66
|
+
if (changed) persist();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { persist, rebuild, sweepZombieRunning };
|
|
70
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent task-id lookup rules.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from the Cordis plugin so prefix resolution remains usable without mounting the
|
|
5
|
+
* master tree and can be verified independently from pane lifecycle code.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type TaskIdResolutionResult =
|
|
9
|
+
| { kind: 'resolved'; taskId: string }
|
|
10
|
+
| { kind: 'ambiguous'; query: string; candidates: string[] }
|
|
11
|
+
| { kind: 'too_short'; query: string }
|
|
12
|
+
| { kind: 'not_found'; query: string };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a full or short task ID against known candidates.
|
|
16
|
+
*
|
|
17
|
+
* Exact matches are accepted regardless of length. Prefix matching requires four characters and
|
|
18
|
+
* rejects ambiguity with a sorted candidate list.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveTaskIdPrefix(
|
|
21
|
+
query: string,
|
|
22
|
+
candidates: Iterable<string>,
|
|
23
|
+
): TaskIdResolutionResult {
|
|
24
|
+
const trimmed = query.trim();
|
|
25
|
+
if (!trimmed) return { kind: 'not_found', query: trimmed };
|
|
26
|
+
|
|
27
|
+
const unique = Array.from(new Set(candidates));
|
|
28
|
+
const exact = unique.find((candidate) => candidate === trimmed);
|
|
29
|
+
if (exact) return { kind: 'resolved', taskId: exact };
|
|
30
|
+
|
|
31
|
+
if (trimmed.length < 4) return { kind: 'too_short', query: trimmed };
|
|
32
|
+
|
|
33
|
+
const lower = trimmed.toLowerCase();
|
|
34
|
+
const matches = unique.filter((candidate) => candidate.toLowerCase().startsWith(lower));
|
|
35
|
+
if (matches.length === 1) return { kind: 'resolved', taskId: matches[0]! };
|
|
36
|
+
if (matches.length > 1) {
|
|
37
|
+
matches.sort();
|
|
38
|
+
return { kind: 'ambiguous', query: trimmed, candidates: matches };
|
|
39
|
+
}
|
|
40
|
+
return { kind: 'not_found', query: trimmed };
|
|
41
|
+
}
|
package/src/subagent-scope.ts
CHANGED
|
@@ -22,12 +22,14 @@ export async function mountSubagentScope(
|
|
|
22
22
|
paneId: string,
|
|
23
23
|
hooks: ScopeHooks = {},
|
|
24
24
|
) {
|
|
25
|
+
// Why no second argument: the pinned cordis version takes only the plugin object here; the pane id
|
|
26
|
+
// already identifies the scope through the plugin name and the effect label.
|
|
25
27
|
return root.plugin({
|
|
26
28
|
name: `subagent:${paneId}`,
|
|
27
29
|
apply(ctx: Context) {
|
|
28
30
|
ctx.effect(() => () => { hooks.onDispose?.(); }, `subagent:${paneId}`);
|
|
29
31
|
},
|
|
30
|
-
}
|
|
32
|
+
});
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export async function disposeSessionRoot(root: Context): Promise<void> {
|