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-poller.ts
CHANGED
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Why: pollLoop mixed herdr I/O with takeover / observation / vacuum decisions.
|
|
5
|
-
* Extracting the decisions makes the 30s observation window vs 60s takeover-idle
|
|
6
|
-
* window explicit and unit-testable without a live pane.
|
|
2
|
+
* Background settlement poller: takeover, blocked gate, observation window, vacuum — plus its
|
|
3
|
+
* per-subagent Cordis scope. `createPoller` owns the I/O loop (the window values are runtime-policy).
|
|
7
4
|
*/
|
|
8
|
-
|
|
9
|
-
import {
|
|
5
|
+
import { appendFileSync } from 'node:fs';
|
|
6
|
+
import { Context } from '@deepseek-ai/cordis';
|
|
7
|
+
import type { HerdrClientLike, HerdrAgentState } from './herdr-client.ts';
|
|
8
|
+
import { applyReportedSessionFile } from './history-store.ts';
|
|
9
|
+
import { runtimePolicy, type RuntimePolicy } from './runtime-policy.ts';
|
|
10
|
+
import type { JevRuntime } from './jev-client.ts';
|
|
11
|
+
import { evaluateSettleVerdict, settleAskIsSafe, settleVerdictRequest } from './jev-core.ts';
|
|
12
|
+
import { formatSettlementNotice, type SettlementNullReason } from './vocab.ts';
|
|
13
|
+
import { buildBlockedGateNotice, sleep, type SubEntry } from './subagent-core.ts';
|
|
14
|
+
import type { SessionIo } from './subagent-session.ts';
|
|
15
|
+
import type { GitIo } from './subagent-spawn.ts';
|
|
10
16
|
|
|
11
17
|
export const TAKEOVER_RECHECK_MS = 5_000;
|
|
12
18
|
export const OBSERVATION_TICK_MS = 1_000;
|
|
13
19
|
|
|
14
|
-
|
|
20
|
+
type HerdrWaitState = 'idle' | 'working' | 'blocked' | 'done' | 'unknown' | null;
|
|
15
21
|
|
|
16
22
|
export type TakeoverTickPlan =
|
|
17
23
|
| { kind: 'ignore' }
|
|
@@ -42,6 +48,7 @@ export type BlockedGatePlan =
|
|
|
42
48
|
| { kind: 'clear-gate' }
|
|
43
49
|
| { kind: 'pass' };
|
|
44
50
|
|
|
51
|
+
/** Blocked is sticky (one notice per gate); any other observed state clears the gate. */
|
|
45
52
|
export function planBlockedGate(state: HerdrWaitState, alreadyNotified: boolean): BlockedGatePlan {
|
|
46
53
|
if (state === 'blocked') return { kind: 'stay-blocked', notify: !alreadyNotified };
|
|
47
54
|
if (state !== null) return { kind: 'clear-gate' };
|
|
@@ -55,6 +62,7 @@ export type ObservationTickPlan =
|
|
|
55
62
|
| { kind: 'wait' }
|
|
56
63
|
| { kind: 'settle' };
|
|
57
64
|
|
|
65
|
+
/** A working pane right after a machine inject is still our own work, not a human takeover. */
|
|
58
66
|
export function planObservationTick(input: {
|
|
59
67
|
observationStartedAt: number | null | undefined;
|
|
60
68
|
now: number;
|
|
@@ -72,18 +80,13 @@ export function planObservationTick(input: {
|
|
|
72
80
|
return { kind: 'settle' };
|
|
73
81
|
}
|
|
74
82
|
|
|
75
|
-
export type VacuumTickPlan = {
|
|
76
|
-
refreshActivity: boolean;
|
|
77
|
-
action: 'pane-closed' | 'timeout' | 'continue';
|
|
78
|
-
};
|
|
79
|
-
|
|
80
83
|
export function planVacuumTick(input: {
|
|
81
84
|
waitState: HerdrWaitState;
|
|
82
85
|
paneAlive: boolean;
|
|
83
86
|
now: number;
|
|
84
87
|
lastActivityAt: number;
|
|
85
88
|
timeoutMs: number;
|
|
86
|
-
}):
|
|
89
|
+
}): { refreshActivity: boolean; action: 'pane-closed' | 'timeout' | 'continue' } {
|
|
87
90
|
const refreshActivity = input.waitState === null;
|
|
88
91
|
const lastActivityAt = refreshActivity ? input.now : input.lastActivityAt;
|
|
89
92
|
if (!input.paneAlive) return { refreshActivity, action: 'pane-closed' };
|
|
@@ -92,39 +95,38 @@ export function planVacuumTick(input: {
|
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* "finished … left no closing message" and one was closed mid-task by GC afterwards.
|
|
98
|
+
* Closing text (only a terminal assistant message produces it) OR an ENDED turn settles; a mere
|
|
99
|
+
* assistant message — the state between tool calls — must NOT qualify, or live workers get announced
|
|
100
|
+
* as finished. OCC compaction hold: a compacting child is not settleable even with closing text, since
|
|
101
|
+
* that turn was aborted on purpose and the continuation is coming — an early wake would be a false
|
|
102
|
+
* settlement.
|
|
101
103
|
*/
|
|
102
104
|
export function isSettlementCandidate(input: {
|
|
103
105
|
text: string | null;
|
|
104
106
|
pendingTool: boolean;
|
|
105
107
|
activity: boolean;
|
|
106
108
|
turnEnded?: boolean;
|
|
109
|
+
compacting?: boolean;
|
|
107
110
|
}): boolean {
|
|
111
|
+
if (input.compacting) return false;
|
|
108
112
|
if (input.text) return true;
|
|
109
113
|
return !input.pendingTool && input.turnEnded === true;
|
|
110
114
|
}
|
|
111
115
|
|
|
112
|
-
/** Formats the combined settlement notice with optional git worktree stat. */
|
|
113
116
|
export function buildSettlementNoticeText(
|
|
114
117
|
agentLabel: string,
|
|
115
118
|
closing: string | null,
|
|
116
119
|
statLine: string | null,
|
|
120
|
+
nullReason: SettlementNullReason = 'silent',
|
|
117
121
|
): string {
|
|
118
|
-
const base = formatSettlementNotice(agentLabel, closing);
|
|
122
|
+
const base = formatSettlementNotice(agentLabel, closing, nullReason);
|
|
119
123
|
return statLine ? `${base}\n${statLine}` : base;
|
|
120
124
|
}
|
|
121
125
|
|
|
122
|
-
/** Formats notice when background subagent pane was closed prematurely. */
|
|
123
126
|
export function formatPaneClosedNotice(paneId: string, description: string): string {
|
|
124
127
|
return `Background subagent ${paneId} (${description}) stopped before settling (its pane closed).`;
|
|
125
128
|
}
|
|
126
129
|
|
|
127
|
-
/** Formats notice when background subagent exceeded observation timeout. */
|
|
128
130
|
export function formatObservationTimeoutNotice(input: {
|
|
129
131
|
paneId: string;
|
|
130
132
|
description: string;
|
|
@@ -134,3 +136,360 @@ export function formatObservationTimeoutNotice(input: {
|
|
|
134
136
|
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
137
|
}
|
|
136
138
|
|
|
139
|
+
/* ── pane scope ─────────────────────────────────────────────────── */
|
|
140
|
+
|
|
141
|
+
export interface ScopeHooks {
|
|
142
|
+
onDispose?: () => void;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createSessionRoot(hooks: ScopeHooks = {}): Context {
|
|
146
|
+
const root = new Context();
|
|
147
|
+
if (hooks.onDispose) {
|
|
148
|
+
root.effect(() => () => { hooks.onDispose?.(); }, 'session-root');
|
|
149
|
+
}
|
|
150
|
+
return root;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function mountSubagentScope(
|
|
154
|
+
root: Context,
|
|
155
|
+
paneId: string,
|
|
156
|
+
hooks: ScopeHooks = {},
|
|
157
|
+
) {
|
|
158
|
+
// One argument only: the pinned cordis version takes just the plugin object; the pane id is the scope name.
|
|
159
|
+
return root.plugin({
|
|
160
|
+
name: `subagent:${paneId}`,
|
|
161
|
+
apply(ctx: Context) {
|
|
162
|
+
ctx.effect(() => () => { hooks.onDispose?.(); }, `subagent:${paneId}`);
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function disposeSessionRoot(root: Context): Promise<void> {
|
|
168
|
+
try {
|
|
169
|
+
await root.fiber.dispose();
|
|
170
|
+
} catch {
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ── poll loop ──────────────────────────────────────────────────── */
|
|
175
|
+
|
|
176
|
+
export interface PollerHost {
|
|
177
|
+
client: HerdrClientLike;
|
|
178
|
+
sessionRoot: Context;
|
|
179
|
+
subs: Map<string, SubEntry>;
|
|
180
|
+
persistSubs(): void;
|
|
181
|
+
writeHistory(e: SubEntry, patch?: { outcome?: string | null; status?: SubEntry['status']; closedAt?: number }, via?: string): void;
|
|
182
|
+
blockedGateNotified: Set<string>;
|
|
183
|
+
lastMachineInjectAt: Map<string, number>;
|
|
184
|
+
session: SessionIo;
|
|
185
|
+
git: GitIo;
|
|
186
|
+
injectNotice(content: string): Promise<void>;
|
|
187
|
+
reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[];
|
|
188
|
+
withReconcileNotes(base: string, notes: readonly string[]): string;
|
|
189
|
+
claimSettleNotice(key: string): boolean;
|
|
190
|
+
sleep?: (ms: number) => Promise<void>;
|
|
191
|
+
now?: () => number;
|
|
192
|
+
policy?: Partial<RuntimePolicy>;
|
|
193
|
+
/** Optional jev seam for the settle attribution check; absent → legacy wording (fail-open). */
|
|
194
|
+
jev?: { ask: JevRuntime['ask']; getMinConfidence: () => number };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface Poller {
|
|
198
|
+
startPoller(paneId: string, cwd: string, injectTs: number, description: string, requestId: string): Promise<void>;
|
|
199
|
+
readonly pollers: Set<string>;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function createPoller(h: PollerHost): Poller {
|
|
203
|
+
const pollers = new Set<string>();
|
|
204
|
+
const subScopes = new Map<string, { dispose: () => Promise<void> }>();
|
|
205
|
+
/** Current tracked request per pane. startPoller refreshes it even while a poller runs: a
|
|
206
|
+
* follow_up must judge settlement against its own injectTs, not the original request's. */
|
|
207
|
+
const requestByPane = new Map<string, { injectTs: number; description: string; requestId: string }>();
|
|
208
|
+
const doSleep = h.sleep ?? sleep;
|
|
209
|
+
const now = h.now ?? Date.now;
|
|
210
|
+
const observeWindowMs = h.policy?.observationWindowMs ?? runtimePolicy.observationWindowMs;
|
|
211
|
+
const machineInjectGraceMs = h.policy?.settlementWindowMs ?? runtimePolicy.settlementWindowMs;
|
|
212
|
+
const takeoverIdleMs = h.policy?.settlementWindowMs ?? runtimePolicy.settlementWindowMs;
|
|
213
|
+
const timeoutMs = h.policy?.subagentTimeoutMs ?? runtimePolicy.subagentTimeoutMs;
|
|
214
|
+
const pollIntervalMs = h.policy?.pollIntervalMs ?? runtimePolicy.pollIntervalMs;
|
|
215
|
+
|
|
216
|
+
async function pollLoop(
|
|
217
|
+
paneId: string,
|
|
218
|
+
cwd: string,
|
|
219
|
+
injectTs: number,
|
|
220
|
+
description: string,
|
|
221
|
+
requestId: string,
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
const startedAt = now();
|
|
224
|
+
let lastActivityAt = now();
|
|
225
|
+
/** Stop watching this pane: persist + ledger first, or a restart replays a ghost running row. */
|
|
226
|
+
const consume = (outcome: string | null, via: string, at: number): void => {
|
|
227
|
+
const row = h.subs.get(paneId);
|
|
228
|
+
if (!row) return;
|
|
229
|
+
row.status = 'consumed';
|
|
230
|
+
row.consumedAt = at;
|
|
231
|
+
h.persistSubs();
|
|
232
|
+
h.writeHistory(row, { outcome }, via);
|
|
233
|
+
};
|
|
234
|
+
const pollTrace = process.env.PI_HERDR_TRACE
|
|
235
|
+
? (msg: string) => { try { appendFileSync(process.env.PI_HERDR_TRACE!, `d98poll ${now()} ${paneId} ${msg}\n`); } catch { /* best-effort */ } }
|
|
236
|
+
: null;
|
|
237
|
+
try {
|
|
238
|
+
while (true) {
|
|
239
|
+
const current = requestByPane.get(paneId) ?? { injectTs, description, requestId };
|
|
240
|
+
const entry = h.subs.get(paneId);
|
|
241
|
+
if (!entry || entry.status === 'settled' || entry.status === 'consumed' || entry.status === 'closed') return;
|
|
242
|
+
|
|
243
|
+
if (entry.userTakeover) {
|
|
244
|
+
try {
|
|
245
|
+
const agent = (await h.client.listAgents()).find((a) => a.paneId === paneId);
|
|
246
|
+
const tick = planTakeoverTick({
|
|
247
|
+
currentStatus: agent?.status ?? null,
|
|
248
|
+
previousStatus: entry.lastAgentStatus,
|
|
249
|
+
idleStartedAt: entry.observationStartedAt,
|
|
250
|
+
now: now(),
|
|
251
|
+
idleMs: takeoverIdleMs,
|
|
252
|
+
});
|
|
253
|
+
if (tick.kind === 'start-idle') {
|
|
254
|
+
entry.observationStartedAt = now();
|
|
255
|
+
entry.lastAgentStatus = 'idle';
|
|
256
|
+
h.persistSubs();
|
|
257
|
+
} else if (tick.kind === 'return-control') {
|
|
258
|
+
entry.userTakeover = false;
|
|
259
|
+
entry.observationStartedAt = null;
|
|
260
|
+
entry.lastAgentStatus = null;
|
|
261
|
+
h.persistSubs();
|
|
262
|
+
} else if (tick.kind === 'hold' && tick.clearIdleTimer) {
|
|
263
|
+
entry.lastAgentStatus = tick.lastAgentStatus;
|
|
264
|
+
entry.observationStartedAt = null;
|
|
265
|
+
h.persistSubs();
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
}
|
|
269
|
+
if (entry.userTakeover) {
|
|
270
|
+
await doSleep(TAKEOVER_RECHECK_MS);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const tickStartedAt = now();
|
|
276
|
+
/** waitAgent returns at once for a pane already idle/done/blocked; never re-poll faster than the interval. */
|
|
277
|
+
const pace = async (): Promise<void> => {
|
|
278
|
+
const left = pollIntervalMs - (now() - tickStartedAt);
|
|
279
|
+
if (left > 0) await doSleep(left);
|
|
280
|
+
};
|
|
281
|
+
let state: HerdrAgentState | null;
|
|
282
|
+
try {
|
|
283
|
+
state = await h.client.waitAgent(paneId, ['idle', 'done', 'blocked'], pollIntervalMs);
|
|
284
|
+
} catch {
|
|
285
|
+
state = null;
|
|
286
|
+
}
|
|
287
|
+
const gate = planBlockedGate(state, h.blockedGateNotified.has(paneId));
|
|
288
|
+
if (gate.kind === 'stay-blocked') {
|
|
289
|
+
if (gate.notify) {
|
|
290
|
+
h.blockedGateNotified.add(paneId);
|
|
291
|
+
const question = await h.session.readAskFlag(paneId);
|
|
292
|
+
try {
|
|
293
|
+
await h.injectNotice(buildBlockedGateNotice({ paneId, description: current.description, question }));
|
|
294
|
+
} catch {
|
|
295
|
+
/* list_agents can recover a missed notice */
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
await pace();
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (gate.kind === 'clear-gate') h.blockedGateNotified.delete(paneId);
|
|
302
|
+
|
|
303
|
+
if (state === 'idle' || state === 'done') {
|
|
304
|
+
// Spawn can mis-attribute entry.sessionFile (herdr's report lags; the mtime fallback records
|
|
305
|
+
// the newest PRE-EXISTING session), freezing settlement detection until the vacuum timeout.
|
|
306
|
+
if (entry.sessionFile) {
|
|
307
|
+
const fixed = await h.session.reattributeStaleSessionFile(paneId, cwd, current.injectTs, entry.sessionFile);
|
|
308
|
+
if (fixed && fixed !== entry.sessionFile) {
|
|
309
|
+
entry.sessionFile = fixed;
|
|
310
|
+
h.persistSubs();
|
|
311
|
+
}
|
|
312
|
+
} else {
|
|
313
|
+
entry.sessionFile = applyReportedSessionFile(
|
|
314
|
+
entry.sessionFile,
|
|
315
|
+
await h.session.resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: current.injectTs - 2_000 }),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
const s = await h.session.subSessionState(paneId, cwd, current.injectTs, entry.sessionFile);
|
|
319
|
+
pollTrace?.(`state=${state} text=${s.text ? s.text.length : 'null'} pend=${s.pendingTool} act=${s.activity} compact=${String(s.compacting)} obs=${String(entry.observationStartedAt ?? null)} takeover=${String(Boolean(entry.userTakeover))}`);
|
|
320
|
+
// OCC compaction is live child work (pane reports idle, transcript holds the inflight
|
|
321
|
+
// marker): keep the vacuum timer fed so the timeout does not fire mid-summary.
|
|
322
|
+
if (s.compacting) lastActivityAt = now();
|
|
323
|
+
|
|
324
|
+
if (isSettlementCandidate(s)) {
|
|
325
|
+
const closing = s.text;
|
|
326
|
+
if (!entry.observationStartedAt) {
|
|
327
|
+
entry.observationStartedAt = now();
|
|
328
|
+
entry.lastAgentStatus = 'idle';
|
|
329
|
+
h.persistSubs();
|
|
330
|
+
await doSleep(OBSERVATION_TICK_MS);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
let agentStatus: string | null = null;
|
|
334
|
+
try {
|
|
335
|
+
agentStatus = (await h.client.listAgents()).find((a) => a.paneId === paneId)?.status ?? null;
|
|
336
|
+
} catch {
|
|
337
|
+
}
|
|
338
|
+
const obs = planObservationTick({
|
|
339
|
+
observationStartedAt: entry.observationStartedAt,
|
|
340
|
+
now: now(),
|
|
341
|
+
windowMs: observeWindowMs,
|
|
342
|
+
agentStatus,
|
|
343
|
+
machineInjectAgoMs: now() - (h.lastMachineInjectAt.get(paneId) ?? 0),
|
|
344
|
+
machineInjectGraceMs,
|
|
345
|
+
});
|
|
346
|
+
if (obs.kind === 'user-takeover') {
|
|
347
|
+
entry.userTakeover = true;
|
|
348
|
+
entry.observationStartedAt = now();
|
|
349
|
+
entry.lastAgentStatus = 'working';
|
|
350
|
+
h.persistSubs();
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
if (obs.kind === 'machine-inject-reset') {
|
|
354
|
+
entry.observationStartedAt = now();
|
|
355
|
+
h.persistSubs();
|
|
356
|
+
await doSleep(OBSERVATION_TICK_MS);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (obs.kind === 'wait') {
|
|
360
|
+
await doSleep(OBSERVATION_TICK_MS);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
entry.observationStartedAt = null;
|
|
364
|
+
|
|
365
|
+
// Null closing text is ambiguous — a mis-attributed transcript reads like a worker that
|
|
366
|
+
// died silent. Deterministic signal first: no readable candidate at all (activity false)
|
|
367
|
+
// makes "left no closing message" a lie; otherwise ask jev to classify the tail we read.
|
|
368
|
+
let nullReason: SettlementNullReason = 'silent';
|
|
369
|
+
if (closing == null) {
|
|
370
|
+
if (!s.activity) {
|
|
371
|
+
nullReason = 'attribution-suspect';
|
|
372
|
+
} else if (h.jev) {
|
|
373
|
+
try {
|
|
374
|
+
const tail = await h.session.readSettleTail(paneId, cwd, entry.sessionFile);
|
|
375
|
+
if (tail && settleAskIsSafe(tail)) {
|
|
376
|
+
const res = await h.jev.ask(
|
|
377
|
+
settleVerdictRequest({ description: current.description, tail }),
|
|
378
|
+
{
|
|
379
|
+
questionId: 'settle-attribution',
|
|
380
|
+
timeoutMs: 2500,
|
|
381
|
+
// Calibration fields (D115): noul answers carry no confidence, so the
|
|
382
|
+
// probabilities and the composed verdict are what SETTLE_*_MIN_NOUL
|
|
383
|
+
// tuning reads from jev.jsonl.
|
|
384
|
+
enrich: ({ ok, answers }) => {
|
|
385
|
+
if (!ok || !answers) return {};
|
|
386
|
+
const match = answers.tail_matches_task;
|
|
387
|
+
const fin = answers.tail_has_final_answer;
|
|
388
|
+
return {
|
|
389
|
+
tailMatchesTaskNoul: match?.type === 'noul' ? match.noul : null,
|
|
390
|
+
tailHasFinalAnswerNoul: fin?.type === 'noul' ? fin.noul : null,
|
|
391
|
+
settleVerdict: evaluateSettleVerdict(answers) ?? 'unanswered',
|
|
392
|
+
};
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
);
|
|
396
|
+
if (res.ok) {
|
|
397
|
+
const verdict = evaluateSettleVerdict(res.answers);
|
|
398
|
+
if (verdict) nullReason = verdict;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
/* jev down → legacy wording */
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (!entry.sessionFile) {
|
|
407
|
+
entry.sessionFile = applyReportedSessionFile(
|
|
408
|
+
entry.sessionFile,
|
|
409
|
+
await h.session.resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: current.injectTs - 2_000 }),
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
consume(closing, 'poll-settle', now());
|
|
413
|
+
const notes = h.reconcileOnSettlement(current.description, 'settled');
|
|
414
|
+
const statLine = await h.git.worktreeStatLine(entry);
|
|
415
|
+
const notice = h.withReconcileNotes(
|
|
416
|
+
buildSettlementNoticeText(`${paneId} (${current.description})`, closing, statLine, nullReason),
|
|
417
|
+
notes,
|
|
418
|
+
);
|
|
419
|
+
if (h.claimSettleNotice(`${paneId}:${current.requestId}`)) {
|
|
420
|
+
try {
|
|
421
|
+
await h.injectNotice(notice);
|
|
422
|
+
} catch {
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
let alive = false;
|
|
430
|
+
try {
|
|
431
|
+
alive = (await h.client.listPanes()).some((p) => p.paneId === paneId);
|
|
432
|
+
} catch {
|
|
433
|
+
alive = true; // an unreachable socket must not be read as a dead pane
|
|
434
|
+
}
|
|
435
|
+
const vacuum = planVacuumTick({ waitState: state, paneAlive: alive, now: now(), lastActivityAt, timeoutMs });
|
|
436
|
+
if (vacuum.refreshActivity) lastActivityAt = now();
|
|
437
|
+
if (vacuum.action === 'pane-closed') {
|
|
438
|
+
consume('pane closed before settling', 'poll-pane-closed', now());
|
|
439
|
+
const notes = h.reconcileOnSettlement(current.description, 'failed');
|
|
440
|
+
try {
|
|
441
|
+
await h.injectNotice(h.withReconcileNotes(formatPaneClosedNotice(paneId, current.description), notes));
|
|
442
|
+
} catch { /* non-fatal */ }
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
if (vacuum.action === 'timeout') {
|
|
446
|
+
consume('observation timeout', 'poll-timeout', now());
|
|
447
|
+
const notes = h.reconcileOnSettlement(current.description, 'failed');
|
|
448
|
+
const notice = h.withReconcileNotes(
|
|
449
|
+
formatObservationTimeoutNotice({
|
|
450
|
+
paneId,
|
|
451
|
+
description: current.description,
|
|
452
|
+
idleSeconds: Math.round((now() - lastActivityAt) / 1000),
|
|
453
|
+
startedAtIso: new Date(startedAt).toISOString(),
|
|
454
|
+
}),
|
|
455
|
+
notes,
|
|
456
|
+
);
|
|
457
|
+
try {
|
|
458
|
+
await h.injectNotice(notice);
|
|
459
|
+
} catch { /* non-fatal */ }
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
await pace();
|
|
463
|
+
}
|
|
464
|
+
} finally {
|
|
465
|
+
pollers.delete(paneId);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function startPoller(paneId: string, cwd: string, injectTs: number, description: string, requestId: string): Promise<void> {
|
|
470
|
+
// Always refresh first: a follow_up arriving while the spawn poller still runs must move
|
|
471
|
+
// settlement judgment to the new request instead of being dropped by the has() guard.
|
|
472
|
+
requestByPane.set(paneId, { injectTs, description, requestId });
|
|
473
|
+
if (pollers.has(paneId)) return Promise.resolve();
|
|
474
|
+
pollers.add(paneId);
|
|
475
|
+
return (async () => {
|
|
476
|
+
try {
|
|
477
|
+
if (!subScopes.has(paneId)) {
|
|
478
|
+
const fiber = await mountSubagentScope(h.sessionRoot, paneId, {
|
|
479
|
+
onDispose: () => { pollers.delete(paneId); },
|
|
480
|
+
});
|
|
481
|
+
subScopes.set(paneId, fiber);
|
|
482
|
+
}
|
|
483
|
+
await pollLoop(paneId, cwd, injectTs, description, requestId);
|
|
484
|
+
const fiber = subScopes.get(paneId);
|
|
485
|
+
subScopes.delete(paneId);
|
|
486
|
+
try { await fiber?.dispose(); } catch { /* already gone */ }
|
|
487
|
+
} catch (err) {
|
|
488
|
+
pollers.delete(paneId);
|
|
489
|
+
console.error(`pier: subagent poller ${paneId} crashed: ${(err as Error)?.message ?? err}`);
|
|
490
|
+
}
|
|
491
|
+
})();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return { startPoller, pollers };
|
|
495
|
+
}
|