piher 0.0.0-stage → 0.2.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 +60 -2
- package/package.json +58 -4
- package/schemas/role-manifest.schema.json +62 -0
- package/skills/piher-tools/SKILL.md +212 -0
- package/src/ansi-text.ts +143 -0
- package/src/bg-job-core.ts +412 -0
- package/src/bootstrap.ts +102 -0
- package/src/config-catalog-core.ts +200 -0
- package/src/config-command.ts +324 -0
- package/src/cordis-services.ts +38 -0
- package/src/core-client.ts +715 -0
- package/src/delegation-core.ts +94 -0
- package/src/gc-core.ts +150 -0
- package/src/guards.ts +6 -0
- package/src/hc-templates.ts +134 -0
- package/src/index-locks.ts +118 -0
- package/src/index-master.ts +92 -0
- package/src/index-notices.ts +79 -0
- package/src/index-pipe.ts +71 -0
- package/src/index-roles.ts +265 -0
- package/src/index-runtime.ts +23 -0
- package/src/index.ts +460 -0
- package/src/ledger.ts +61 -0
- package/src/lock-core.ts +200 -0
- package/src/manifest-compose.ts +103 -0
- package/src/options.ts +78 -0
- package/src/pi-surface.ts +140 -0
- package/src/pipe-channel.ts +291 -0
- package/src/platform-paths.ts +37 -0
- package/src/plugins/bg-job.ts +560 -0
- package/src/plugins/subagent.ts +1244 -0
- package/src/prompt-store.ts +50 -0
- package/src/renderers.ts +195 -0
- package/src/role-catalog.ts +99 -0
- package/src/role-loader.ts +196 -0
- package/src/role-manifest.ts +163 -0
- package/src/role-state.ts +119 -0
- package/src/roles/master.json +23 -0
- package/src/roles/worker-default.json +23 -0
- package/src/runtime-policy.ts +45 -0
- package/src/session-state.ts +19 -0
- package/src/session-tail.ts +276 -0
- package/src/settle-wake-core.ts +73 -0
- package/src/settlement-card.ts +96 -0
- package/src/storage-layout.ts +92 -0
- package/src/subagent-core.ts +543 -0
- package/src/subagent-gc.ts +312 -0
- package/src/subagent-poller.ts +484 -0
- package/src/subagent-session.ts +548 -0
- package/src/subagent-spawn.ts +695 -0
- package/src/swallow.ts +59 -0
- package/src/task-row.ts +257 -0
- package/src/task-store.ts +173 -0
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +99 -0
- package/src/tree-view.ts +58 -0
- package/src/vocab.ts +41 -0
- package/src/worker-marker.ts +65 -0
- package/src/worktree-io.ts +177 -0
- package/src/worktree.ts +280 -0
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background settlement watcher: blocked gate, settle window, vacuum — plus its per-subagent
|
|
3
|
+
* Cordis scope. Each loop reads the worker from the master's `WorkerWatch` snapshot and parks on its
|
|
4
|
+
* change counter, so a worker that is working, blocked or idle costs no RPCs between events.
|
|
5
|
+
*/
|
|
6
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
7
|
+
import type { CoreClientLike, CoreStatus, TerminalInfo } from './core-client.ts';
|
|
8
|
+
import {
|
|
9
|
+
applyReportedSessionFile, isClosed, taskForPane, type TaskEntry, type TaskObservation,
|
|
10
|
+
} from './task-row.ts';
|
|
11
|
+
import { runtimePolicy, type RuntimePolicy } from './runtime-policy.ts';
|
|
12
|
+
import { formatFailureNotice, formatSettlementNotice, type SettlementNullReason } from './vocab.ts';
|
|
13
|
+
import { settlementCard, type SettlementCard, type SettlementOutcome } from './settlement-card.ts';
|
|
14
|
+
import { buildBlockedGateNotice, reportFingerprint } from './subagent-core.ts';
|
|
15
|
+
import { retirePane } from './subagent-gc.ts';
|
|
16
|
+
import type { SessionIo } from './subagent-session.ts';
|
|
17
|
+
|
|
18
|
+
/** `null` = no word to act on (working, never reported, or the watch is blind). */
|
|
19
|
+
type WaitState = CoreStatus | null;
|
|
20
|
+
|
|
21
|
+
export type BlockedGatePlan =
|
|
22
|
+
| { kind: 'stay-blocked'; notify: boolean }
|
|
23
|
+
| { kind: 'clear-gate' }
|
|
24
|
+
| { kind: 'pass' };
|
|
25
|
+
|
|
26
|
+
/** Blocked is sticky (one notice per gate); any other observed state clears the gate. */
|
|
27
|
+
export function planBlockedGate(state: WaitState, alreadyNotified: boolean): BlockedGatePlan {
|
|
28
|
+
if (state === 'blocked') return { kind: 'stay-blocked', notify: !alreadyNotified };
|
|
29
|
+
if (state !== null) return { kind: 'clear-gate' };
|
|
30
|
+
return { kind: 'pass' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type SettleWindowTickPlan =
|
|
34
|
+
| { kind: 'start-settle-window' }
|
|
35
|
+
| { kind: 'wait' }
|
|
36
|
+
| { kind: 'settle' };
|
|
37
|
+
|
|
38
|
+
/** The window delays settlement so a worker is not announced before its transcript settles; a
|
|
39
|
+
* finalized answer on a terminal that is not working skips it (the transcript cannot grow). */
|
|
40
|
+
export function planSettleWindowTick(input: {
|
|
41
|
+
settleWindowStartedAt: number | null | undefined;
|
|
42
|
+
now: number;
|
|
43
|
+
windowMs: number;
|
|
44
|
+
/** The transcript already holds a finalized answer (stopReason 'stop', no pending tool call). */
|
|
45
|
+
finalized?: boolean;
|
|
46
|
+
}): SettleWindowTickPlan {
|
|
47
|
+
if (!input.settleWindowStartedAt) return input.finalized ? { kind: 'settle' } : { kind: 'start-settle-window' };
|
|
48
|
+
if (input.now - input.settleWindowStartedAt < input.windowMs) return { kind: 'wait' };
|
|
49
|
+
return { kind: 'settle' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function planVacuumTick(input: {
|
|
53
|
+
waitState: WaitState;
|
|
54
|
+
paneAlive: boolean;
|
|
55
|
+
now: number;
|
|
56
|
+
lastActivityAt: number;
|
|
57
|
+
timeoutMs: number;
|
|
58
|
+
}): { refreshActivity: boolean; action: 'pane-closed' | 'timeout' | 'continue' } {
|
|
59
|
+
const refreshActivity = input.waitState === null;
|
|
60
|
+
const lastActivityAt = refreshActivity ? input.now : input.lastActivityAt;
|
|
61
|
+
if (!input.paneAlive) return { refreshActivity, action: 'pane-closed' };
|
|
62
|
+
if (input.now - lastActivityAt > input.timeoutMs) return { refreshActivity, action: 'timeout' };
|
|
63
|
+
return { refreshActivity, action: 'continue' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Closing text (only a terminal assistant message produces it) OR an ENDED turn settles; a mere
|
|
68
|
+
* assistant message — the state between tool calls — must NOT qualify, or live workers get announced
|
|
69
|
+
* as finished.
|
|
70
|
+
*
|
|
71
|
+
* The exception is an idle terminal with no tool in flight: a worker that stopped between calls is not
|
|
72
|
+
* "between calls". Without it, a run that ended on a refused tool call (last message `toolUse`, result
|
|
73
|
+
* already returned, nothing after) stayed invisible for the whole vacuum budget.
|
|
74
|
+
*/
|
|
75
|
+
export function isSettlementCandidate(input: {
|
|
76
|
+
text: string | null;
|
|
77
|
+
pendingTool: boolean;
|
|
78
|
+
activity: boolean;
|
|
79
|
+
turnEnded?: boolean;
|
|
80
|
+
/** The pane itself is idle: nothing is being executed right now. */
|
|
81
|
+
terminalIdle?: boolean;
|
|
82
|
+
}): boolean {
|
|
83
|
+
if (input.text) return true;
|
|
84
|
+
if (!input.pendingTool && input.turnEnded === true) return true;
|
|
85
|
+
return !input.pendingTool && input.activity === true && input.terminalIdle === true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** How long one closing text counts as announced for a pane (see noticeAnnounced). */
|
|
89
|
+
const NOTICE_DEDUPE_WINDOW_MS = 5 * 60_000;
|
|
90
|
+
|
|
91
|
+
export function buildSettlementNoticeText(
|
|
92
|
+
agentLabel: string,
|
|
93
|
+
closing: string | null,
|
|
94
|
+
nullReason: SettlementNullReason = 'silent',
|
|
95
|
+
reportDeliveredAt: number | null = null,
|
|
96
|
+
): string {
|
|
97
|
+
return formatSettlementNotice(agentLabel, closing, nullReason, reportDeliveredAt);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function formatPaneClosedNotice(paneId: string, description: string): string {
|
|
101
|
+
return `Background subagent ${paneId} (${description}) stopped before settling (its pane closed).`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function formatSettleTimeoutNotice(input: {
|
|
105
|
+
paneId: string;
|
|
106
|
+
description: string;
|
|
107
|
+
idleSeconds: number;
|
|
108
|
+
startedAtIso: string;
|
|
109
|
+
}): string {
|
|
110
|
+
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.`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* ── pane scope ─────────────────────────────────────────────────── */
|
|
114
|
+
|
|
115
|
+
/** A revived pane can answer in the very second the poller starts. The transcript is read from the
|
|
116
|
+
* request boundary on, so an answer landing exactly on it used to read as no activity at all: the row
|
|
117
|
+
* then sat `running` until the settle timeout declared a worker that had finished to be `failed`. */
|
|
118
|
+
const REQUEST_BOUNDARY_SLACK_MS = 2_000;
|
|
119
|
+
|
|
120
|
+
export interface ScopeHooks {
|
|
121
|
+
onDispose?: () => void;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function createSessionRoot(hooks: ScopeHooks = {}): Context {
|
|
125
|
+
const root = new Context();
|
|
126
|
+
if (hooks.onDispose) {
|
|
127
|
+
root.effect(() => () => { hooks.onDispose?.(); }, 'session-root');
|
|
128
|
+
}
|
|
129
|
+
return root;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function mountSubagentScope(
|
|
133
|
+
root: Context,
|
|
134
|
+
paneId: string,
|
|
135
|
+
hooks: ScopeHooks = {},
|
|
136
|
+
) {
|
|
137
|
+
// One argument only: the pinned cordis version takes just the plugin object; the pane id is the scope name.
|
|
138
|
+
return root.plugin({
|
|
139
|
+
name: `subagent:${paneId}`,
|
|
140
|
+
apply(ctx: Context) {
|
|
141
|
+
ctx.effect(() => () => { hooks.onDispose?.(); }, `subagent:${paneId}`);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function disposeSessionRoot(root: Context): Promise<void> {
|
|
147
|
+
try {
|
|
148
|
+
await root.fiber.dispose();
|
|
149
|
+
} catch {
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ── poll loop ──────────────────────────────────────────────────── */
|
|
154
|
+
|
|
155
|
+
export interface PollerHost {
|
|
156
|
+
client: CoreClientLike;
|
|
157
|
+
sessionRoot: Context;
|
|
158
|
+
tasks: Map<string, TaskEntry>;
|
|
159
|
+
panes: Map<string, string>;
|
|
160
|
+
writeRecord(e: TaskEntry): void;
|
|
161
|
+
/** Refreshed from each observation the loop makes; feeds only the state text (S26). */
|
|
162
|
+
noteObservation?: (paneId: string, obs: TaskObservation) => void;
|
|
163
|
+
blockedGateNotified: Set<string>;
|
|
164
|
+
session: SessionIo;
|
|
165
|
+
injectNotice(content: string, paneId?: string, card?: SettlementCard | null): Promise<void>;
|
|
166
|
+
/** S54/S71: what a one-shot settlement reclaims — the pane, and any checkout its spawn created. */
|
|
167
|
+
reclaim?(entry: TaskEntry): void;
|
|
168
|
+
claimSettleNotice(key: string): boolean;
|
|
169
|
+
now?: () => number;
|
|
170
|
+
policy?: Partial<RuntimePolicy>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface Poller {
|
|
174
|
+
startPoller(paneId: string, cwd: string, injectTs: number, description: string, requestId: string): Promise<void>;
|
|
175
|
+
readonly pollers: Set<string>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function createPoller(h: PollerHost): Poller {
|
|
179
|
+
const pollers = new Set<string>();
|
|
180
|
+
const subScopes = new Map<string, { dispose: () => Promise<void> }>();
|
|
181
|
+
/** Current tracked request per pane. startPoller refreshes it even while a poller runs: a
|
|
182
|
+
* follow_up must judge settlement against its own injectTs, not the original request's. */
|
|
183
|
+
const requestByPane = new Map<string, { injectTs: number; description: string; requestId: string }>();
|
|
184
|
+
const now = h.now ?? Date.now;
|
|
185
|
+
const settleWindowMs = h.policy?.settleWindowMs ?? runtimePolicy.settleWindowMs;
|
|
186
|
+
const timeoutMs = h.policy?.subagentTimeoutMs ?? runtimePolicy.subagentTimeoutMs;
|
|
187
|
+
/** Safety re-check while no event arrives; events, not this interval, drive the loop. */
|
|
188
|
+
const recheckMs = h.policy?.pollIntervalMs ?? runtimePolicy.pollIntervalMs;
|
|
189
|
+
const watch = h.client.watchChildren();
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The card a poller decision draws: a failure carries the same role and elapsed fields a settle
|
|
193
|
+
* does, so the two never read as different kinds of event.
|
|
194
|
+
*/
|
|
195
|
+
const cardFor = (
|
|
196
|
+
entry: TaskEntry | undefined,
|
|
197
|
+
paneId: string,
|
|
198
|
+
description: string,
|
|
199
|
+
outcome: SettlementOutcome,
|
|
200
|
+
): SettlementCard => settlementCard({
|
|
201
|
+
name: `${paneId} (${description})`,
|
|
202
|
+
...(entry ? { kind: entry.kind, startedAt: entry.createdAt } : {}),
|
|
203
|
+
outcome,
|
|
204
|
+
now: now(),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
/** The worker as the watch sees it. A blind watch (disconnected) never settles or fails anything. */
|
|
208
|
+
async function observe(paneId: string): Promise<{ state: CoreStatus | 'exited' | null; alive: boolean }> {
|
|
209
|
+
if (!watch.connected) return { state: null, alive: true };
|
|
210
|
+
let info: TerminalInfo | null | undefined = watch.get(paneId);
|
|
211
|
+
if (!info) {
|
|
212
|
+
// Not in the snapshot: a fresh spawn racing its `created` event, or a terminal that is gone.
|
|
213
|
+
try {
|
|
214
|
+
info = await h.client.getTerminal(paneId);
|
|
215
|
+
} catch {
|
|
216
|
+
return { state: null, alive: true }; // an unreachable socket must not be read as a dead terminal
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!info || info.state === 'finished') return { state: 'exited', alive: false };
|
|
220
|
+
return { state: info.status, alive: true };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function pollLoop(
|
|
224
|
+
paneId: string,
|
|
225
|
+
cwd: string,
|
|
226
|
+
injectTs: number,
|
|
227
|
+
description: string,
|
|
228
|
+
requestId: string,
|
|
229
|
+
): Promise<void> {
|
|
230
|
+
const startedAt = now();
|
|
231
|
+
let lastActivityAt = now();
|
|
232
|
+
/** Claimed settle key: on a rejected injection the claim stays ours, so the next wake retries
|
|
233
|
+
* the notice instead of suppressing it as someone else's. */
|
|
234
|
+
let claimedKey: string | null = null;
|
|
235
|
+
/** Stop watching this pane: stamp the delivery and write the record first, or a restart
|
|
236
|
+
* replays a ghost running row. Settling is `consumedAt` plus the write — nothing else. */
|
|
237
|
+
const consume = (at: number): void => {
|
|
238
|
+
const row = taskForPane(h.tasks, h.panes, paneId);
|
|
239
|
+
if (!row) return;
|
|
240
|
+
row.gen.consumedAt = at;
|
|
241
|
+
h.writeRecord(row);
|
|
242
|
+
// S54: a one-shot worker exits after pushing its report, so its pane is reclaimed here — the
|
|
243
|
+
// notice already carries the text, and the pane must not linger for the GC TTL.
|
|
244
|
+
if (row.lifecycle === 'one-shot') {
|
|
245
|
+
if (h.reclaim) h.reclaim(row);
|
|
246
|
+
else void retirePane(h.client, paneId);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
/** Hand one settlement to the master: claim, notice, consume. The row is consumed only after the
|
|
250
|
+
* notice is accepted, and a claim we own is retried on the next wake — a rejected injection must
|
|
251
|
+
* not strand a finished worker as `running` with no notice, nor consume the row while the result
|
|
252
|
+
* is still undelivered. Returns 'retry' when the notice was refused. */
|
|
253
|
+
async function handOver(
|
|
254
|
+
entry: TaskEntry,
|
|
255
|
+
closing: string | null,
|
|
256
|
+
activity: boolean,
|
|
257
|
+
request: { injectTs: number; description: string; requestId: string },
|
|
258
|
+
/** The turn ended with no report: the transcript's own reason (an error, a refused tool). A
|
|
259
|
+
* settlement notice over it would read as success. */
|
|
260
|
+
failure: string | null = null,
|
|
261
|
+
): Promise<'done' | 'retry'> {
|
|
262
|
+
// Null closing text is ambiguous — a mis-attributed transcript reads like a worker that died
|
|
263
|
+
// silent — so the transcript is the only signal: no readable candidate at all (activity false)
|
|
264
|
+
// makes "left no closing message" a lie.
|
|
265
|
+
const nullReason: SettlementNullReason = closing == null && !activity ? 'attribution-suspect' : 'silent';
|
|
266
|
+
if (!entry.gen.sessionFile) {
|
|
267
|
+
entry.gen.sessionFile = applyReportedSessionFile(
|
|
268
|
+
entry.gen.sessionFile,
|
|
269
|
+
await h.session.resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: request.injectTs - 2_000 }),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
const claimKey = `${paneId}:${request.requestId}`;
|
|
273
|
+
if (claimedKey !== claimKey) {
|
|
274
|
+
const claimed = h.claimSettleNotice(claimKey);
|
|
275
|
+
if (!claimed) {
|
|
276
|
+
consume(now());
|
|
277
|
+
return 'done';
|
|
278
|
+
}
|
|
279
|
+
claimedKey = claimKey;
|
|
280
|
+
}
|
|
281
|
+
// The same closing text, already announced for this pane, is one settlement: a recovery re-arm and
|
|
282
|
+
// a live request carry different ids, so the claim alone lets the master read it twice. The window
|
|
283
|
+
// keeps a genuinely new turn that happens to say the same words from being swallowed.
|
|
284
|
+
const fingerprint = closing === null ? null : reportFingerprint(closing);
|
|
285
|
+
const announced = entry.noticeAnnounced;
|
|
286
|
+
if (fingerprint !== null && announced && announced.fingerprint === fingerprint
|
|
287
|
+
&& now() - announced.at < NOTICE_DEDUPE_WINDOW_MS) {
|
|
288
|
+
consume(now());
|
|
289
|
+
return 'done';
|
|
290
|
+
}
|
|
291
|
+
// `subagent output` may already have returned this exact report: point there, don't repeat it.
|
|
292
|
+
const delivered = closing && entry.gen.reportDelivered?.fingerprint === reportFingerprint(closing)
|
|
293
|
+
? entry.gen.reportDelivered.at
|
|
294
|
+
: null;
|
|
295
|
+
const notice = failure !== null
|
|
296
|
+
? formatFailureNotice(`${paneId} (${request.description})`, failure)
|
|
297
|
+
: buildSettlementNoticeText(`${paneId} (${request.description})`, closing, nullReason, delivered);
|
|
298
|
+
try {
|
|
299
|
+
// Tag the pane: a notice buffered while the master is busy must keep its row GC-exempt
|
|
300
|
+
// until the flush actually delivers it.
|
|
301
|
+
await h.injectNotice(notice, paneId, cardFor(entry, paneId, request.description, failure !== null ? 'failed' : 'settled'));
|
|
302
|
+
} catch {
|
|
303
|
+
return 'retry';
|
|
304
|
+
}
|
|
305
|
+
if (fingerprint !== null) {
|
|
306
|
+
entry.noticeAnnounced = { fingerprint, at: now() };
|
|
307
|
+
}
|
|
308
|
+
consume(now());
|
|
309
|
+
return 'done';
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await watch.ready();
|
|
313
|
+
try {
|
|
314
|
+
while (true) {
|
|
315
|
+
const current = requestByPane.get(paneId) ?? { injectTs, description, requestId };
|
|
316
|
+
const entry = taskForPane(h.tasks, h.panes, paneId);
|
|
317
|
+
if (!entry || isClosed(entry) || entry.gen.consumedAt !== null) return;
|
|
318
|
+
|
|
319
|
+
// Read the version BEFORE the state: an event landing in between still wakes the park below.
|
|
320
|
+
const seen = watch.version(paneId);
|
|
321
|
+
const park = async (ms: number): Promise<boolean> => {
|
|
322
|
+
const woke = await watch.changed(paneId, seen, Math.max(0, ms));
|
|
323
|
+
return woke;
|
|
324
|
+
};
|
|
325
|
+
const { state, alive } = await observe(paneId);
|
|
326
|
+
// The loop's own observations refresh the display cache; a blind watch notes nothing, so a
|
|
327
|
+
// stale snapshot can never overwrite a liveness the loop actually saw (S26).
|
|
328
|
+
if (watch.connected) {
|
|
329
|
+
h.noteObservation?.(paneId, {
|
|
330
|
+
paneExists: alive,
|
|
331
|
+
agentStatus: state === 'exited' ? null : state,
|
|
332
|
+
lastActivityMs: null,
|
|
333
|
+
at: now(),
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
// Working is activity (vacuum heartbeat); `exited`/`unknown` carry no word to gate or settle on.
|
|
337
|
+
const waitState: WaitState = state === 'exited' || state === 'unknown' || state === 'working' ? null : state;
|
|
338
|
+
if (state === 'working' && entry.settleWindowStartedAt) {
|
|
339
|
+
// A new turn restarts the settle window from scratch.
|
|
340
|
+
entry.settleWindowStartedAt = null;
|
|
341
|
+
}
|
|
342
|
+
const gate = planBlockedGate(waitState, h.blockedGateNotified.has(paneId));
|
|
343
|
+
if (gate.kind === 'stay-blocked') {
|
|
344
|
+
if (gate.notify) {
|
|
345
|
+
h.blockedGateNotified.add(paneId);
|
|
346
|
+
const question = await h.session.readAskFlag(paneId);
|
|
347
|
+
try {
|
|
348
|
+
await h.injectNotice(buildBlockedGateNotice({ paneId, description: current.description, question }));
|
|
349
|
+
} catch {
|
|
350
|
+
/* list_agents can recover a missed notice */
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
lastActivityAt = now(); // a person is being asked: waiting on them is not a stall
|
|
354
|
+
await park(recheckMs);
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (gate.kind === 'clear-gate') h.blockedGateNotified.delete(paneId);
|
|
358
|
+
|
|
359
|
+
if (state === 'idle' || state === 'done' || state === 'error') {
|
|
360
|
+
// Spawn can mis-attribute the generation's sessionFile (the `piher.session` label lags;
|
|
361
|
+
// the mtime fallback records the newest PRE-EXISTING session), freezing settlement
|
|
362
|
+
// detection until the vacuum timeout.
|
|
363
|
+
if (entry.gen.sessionFile) {
|
|
364
|
+
const fixed = await h.session.reattributeStaleSessionFile(paneId, cwd, current.injectTs, entry.gen.sessionFile);
|
|
365
|
+
if (fixed && fixed !== entry.gen.sessionFile) {
|
|
366
|
+
entry.gen.sessionFile = fixed;
|
|
367
|
+
h.writeRecord(entry);
|
|
368
|
+
}
|
|
369
|
+
} else {
|
|
370
|
+
entry.gen.sessionFile = applyReportedSessionFile(
|
|
371
|
+
entry.gen.sessionFile,
|
|
372
|
+
await h.session.resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: current.injectTs - 2_000 }),
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
const s = await h.session.subSessionState(paneId, cwd, current.injectTs, entry.gen.sessionFile);
|
|
376
|
+
|
|
377
|
+
const terminalIdle = state === 'idle' || state === 'done' || state === 'error';
|
|
378
|
+
const candidate = isSettlementCandidate({ ...s, terminalIdle });
|
|
379
|
+
if (candidate) {
|
|
380
|
+
// With no closing words, a stated reason (refused tool, errored turn) IS the report.
|
|
381
|
+
const closing = s.text ?? s.why ?? null;
|
|
382
|
+
const tick = planSettleWindowTick({
|
|
383
|
+
settleWindowStartedAt: entry.settleWindowStartedAt,
|
|
384
|
+
now: now(),
|
|
385
|
+
windowMs: settleWindowMs,
|
|
386
|
+
finalized: closing != null && !s.pendingTool,
|
|
387
|
+
});
|
|
388
|
+
if (tick.kind === 'start-settle-window') {
|
|
389
|
+
entry.settleWindowStartedAt = now();
|
|
390
|
+
await park(settleWindowMs);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (tick.kind === 'wait') {
|
|
394
|
+
await park(settleWindowMs - (now() - (entry.settleWindowStartedAt ?? now())));
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
entry.settleWindowStartedAt = null;
|
|
398
|
+
// No text but a stated reason means the turn died (a provider error, a refused tool): the
|
|
399
|
+
// reason is the diagnosis, never a report.
|
|
400
|
+
const failure = s.text === null ? (s.why ?? null) : null;
|
|
401
|
+
if (await handOver(entry, closing, s.activity, current, failure) === 'retry') {
|
|
402
|
+
// Notification refused (e.g. the master is mid-turn): leave the row running and
|
|
403
|
+
// re-detect the same closing text on the next wake.
|
|
404
|
+
await park(recheckMs);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const vacuum = planVacuumTick({ waitState, paneAlive: alive, now: now(), lastActivityAt, timeoutMs });
|
|
412
|
+
if (vacuum.refreshActivity) lastActivityAt = now();
|
|
413
|
+
if (vacuum.action === 'pane-closed') {
|
|
414
|
+
consume(now());
|
|
415
|
+
try {
|
|
416
|
+
await h.injectNotice(formatPaneClosedNotice(paneId, current.description), paneId,
|
|
417
|
+
cardFor(entry, paneId, current.description, 'failed'));
|
|
418
|
+
} catch { /* non-fatal */ }
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (vacuum.action === 'timeout') {
|
|
422
|
+
// The turn can end without a closing text readable after the request boundary (a revived
|
|
423
|
+
// pane answering in the same second, a transcript re-attributed mid-run). Before declaring
|
|
424
|
+
// `failed`, read the transcript once more with no boundary: an assistant answer sitting at
|
|
425
|
+
// the end of it IS the settlement, and "failed" for a worker that finished is a lie.
|
|
426
|
+
const late = await h.session.subSessionState(paneId, cwd, 0, entry.gen.sessionFile).catch(() => null);
|
|
427
|
+
const lateClosing = late?.text ?? late?.why ?? null;
|
|
428
|
+
if (late && lateClosing != null && !late.pendingTool) {
|
|
429
|
+
entry.settleWindowStartedAt = null;
|
|
430
|
+
const lateFailure = late.text === null ? (late.why ?? null) : null;
|
|
431
|
+
if (await handOver(entry, lateClosing, late.activity, current, lateFailure) === 'retry') {
|
|
432
|
+
await park(recheckMs);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
consume(now());
|
|
438
|
+
const notice = formatSettleTimeoutNotice({
|
|
439
|
+
paneId,
|
|
440
|
+
description: current.description,
|
|
441
|
+
idleSeconds: Math.round((now() - lastActivityAt) / 1000),
|
|
442
|
+
startedAtIso: new Date(startedAt).toISOString(),
|
|
443
|
+
});
|
|
444
|
+
try {
|
|
445
|
+
await h.injectNotice(notice, paneId,
|
|
446
|
+
cardFor(entry, paneId, current.description, 'failed'));
|
|
447
|
+
} catch { /* non-fatal */ }
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
await park(Math.min(recheckMs, timeoutMs - (now() - lastActivityAt)));
|
|
451
|
+
}
|
|
452
|
+
} finally {
|
|
453
|
+
pollers.delete(paneId);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function startPoller(paneId: string, cwd: string, injectTs: number, description: string, requestId: string): Promise<void> {
|
|
458
|
+
// Always refresh first: a follow_up arriving while the spawn poller still runs must move
|
|
459
|
+
// settlement judgment to the new request instead of being dropped by the has() guard.
|
|
460
|
+
const sinceTs = Math.max(0, injectTs - REQUEST_BOUNDARY_SLACK_MS);
|
|
461
|
+
requestByPane.set(paneId, { injectTs: sinceTs, description, requestId });
|
|
462
|
+
if (pollers.has(paneId)) return Promise.resolve();
|
|
463
|
+
pollers.add(paneId);
|
|
464
|
+
return (async () => {
|
|
465
|
+
try {
|
|
466
|
+
if (!subScopes.has(paneId)) {
|
|
467
|
+
const fiber = await mountSubagentScope(h.sessionRoot, paneId, {
|
|
468
|
+
onDispose: () => { pollers.delete(paneId); },
|
|
469
|
+
});
|
|
470
|
+
subScopes.set(paneId, fiber);
|
|
471
|
+
}
|
|
472
|
+
await pollLoop(paneId, cwd, sinceTs, description, requestId);
|
|
473
|
+
const fiber = subScopes.get(paneId);
|
|
474
|
+
subScopes.delete(paneId);
|
|
475
|
+
try { await fiber?.dispose(); } catch { /* already gone */ }
|
|
476
|
+
} catch (err) {
|
|
477
|
+
pollers.delete(paneId);
|
|
478
|
+
console.error(`piher: subagent poller ${paneId} crashed: ${(err as Error)?.message ?? err}`);
|
|
479
|
+
}
|
|
480
|
+
})();
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return { startPoller, pollers };
|
|
484
|
+
}
|