@comput/pi-intercom 0.5.2 → 0.8.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 +109 -15
- package/dist/config.d.ts +18 -1
- package/dist/config.js +141 -27
- package/dist/config.js.map +1 -1
- package/dist/handoff.d.ts +87 -0
- package/dist/handoff.js +327 -0
- package/dist/handoff.js.map +1 -0
- package/dist/index.js +92 -17
- package/dist/index.js.map +1 -1
- package/dist/launcher.js +11 -1
- package/dist/launcher.js.map +1 -1
- package/dist/monitor-view.d.ts +1 -1
- package/dist/monitor-view.js +31 -2
- package/dist/monitor-view.js.map +1 -1
- package/dist/observability.d.ts +1 -1
- package/dist/observability.js +1 -1
- package/dist/observability.js.map +1 -1
- package/dist/pane-close.d.ts +22 -0
- package/dist/pane-close.js +166 -0
- package/dist/pane-close.js.map +1 -0
- package/dist/reminders.d.ts +28 -0
- package/dist/reminders.js +81 -0
- package/dist/reminders.js.map +1 -0
- package/dist/runtime.d.ts +19 -2
- package/dist/runtime.js +163 -16
- package/dist/runtime.js.map +1 -1
- package/dist/snapshot.d.ts +9 -0
- package/dist/snapshot.js +23 -1
- package/dist/snapshot.js.map +1 -1
- package/dist/transport.d.ts +2 -1
- package/dist/transport.js +4 -1
- package/dist/transport.js.map +1 -1
- package/dist/worker-status.d.ts +1 -1
- package/dist/worker-status.js +9 -1
- package/dist/worker-status.js.map +1 -1
- package/package.json +12 -6
- package/references/implementation-blocker.md +1 -1
- package/references/observability.md +13 -3
- package/references/releasing.md +90 -0
- package/references/v1-specification.md +4 -4
- package/src/config.ts +107 -19
- package/src/handoff.ts +280 -0
- package/src/index.ts +75 -15
- package/src/launcher.ts +9 -1
- package/src/monitor-view.ts +30 -3
- package/src/observability.ts +1 -1
- package/src/pane-close.ts +138 -0
- package/src/reminders.ts +69 -0
- package/src/runtime.ts +138 -18
- package/src/snapshot.ts +22 -1
- package/src/transport.ts +4 -2
- package/src/worker-status.ts +9 -2
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { open, readFile, realpath } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import type { Run } from './launcher.js';
|
|
7
|
+
import type { CloseProvider, WorkerPaneIdentity } from './handoff.js';
|
|
8
|
+
|
|
9
|
+
interface ProcessIdentity { start: string; state: string }
|
|
10
|
+
export interface PaneCloseOptions {
|
|
11
|
+
sessionId(): string;
|
|
12
|
+
sessionFile(): string | undefined;
|
|
13
|
+
env?: NodeJS.ProcessEnv;
|
|
14
|
+
platform?: string;
|
|
15
|
+
pid?: number;
|
|
16
|
+
run?: Run;
|
|
17
|
+
processIdentity?: (pid: number) => Promise<ProcessIdentity | undefined>;
|
|
18
|
+
verifySession?: (file: string, id: string) => Promise<void>;
|
|
19
|
+
verificationMs?: number;
|
|
20
|
+
}
|
|
21
|
+
const rejected = () => new Error('PiIntercom: worker pane identity could not be verified; no close submitted.');
|
|
22
|
+
const exec: Run = async (file, args) => (await promisify(execFile)(file, args, {
|
|
23
|
+
windowsHide: true, timeout: 4000, maxBuffer: 1024 * 1024,
|
|
24
|
+
})).stdout;
|
|
25
|
+
|
|
26
|
+
/** Linux process start ticks distinguish a reused PID from the original worker. */
|
|
27
|
+
export async function linuxProcessIdentity(pid: number): Promise<ProcessIdentity | undefined> {
|
|
28
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) throw rejected();
|
|
29
|
+
let stat: string;
|
|
30
|
+
try { stat = await readFile(`/proc/${pid}/stat`, 'utf8'); }
|
|
31
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; throw rejected(); }
|
|
32
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).trim().split(/\s+/);
|
|
33
|
+
if (!/^[0-9]+$/.test(fields[19] ?? '') || !/^[A-Za-z]$/.test(fields[0] ?? '')) throw rejected();
|
|
34
|
+
return { start: fields[19], state: fields[0] };
|
|
35
|
+
}
|
|
36
|
+
async function verifySessionHeader(file: string, id: string): Promise<void> {
|
|
37
|
+
if (!path.isAbsolute(file) || file.length > 4096 || file.includes('\0')) throw rejected();
|
|
38
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
39
|
+
try {
|
|
40
|
+
if (!(await handle.stat()).isFile()) throw rejected();
|
|
41
|
+
// Inspect only the first session-header line, never transcript messages.
|
|
42
|
+
const buffer = Buffer.alloc(4096);
|
|
43
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
44
|
+
const end = buffer.subarray(0, bytesRead).indexOf(10);
|
|
45
|
+
if (end < 0) throw rejected();
|
|
46
|
+
const header = JSON.parse(buffer.subarray(0, end).toString('utf8'));
|
|
47
|
+
if (header?.type !== 'session' || header.id !== id) throw rejected();
|
|
48
|
+
} finally { await handle.close(); }
|
|
49
|
+
}
|
|
50
|
+
const alive = (identity: ProcessIdentity | undefined, start: string) =>
|
|
51
|
+
!!identity && identity.start === start && !['Z', 'X', 'x'].includes(identity.state);
|
|
52
|
+
|
|
53
|
+
/** Linux Herdr only. Identity-checked best effort; Herdr has no atomic compare-and-close API. */
|
|
54
|
+
export function createCloseProvider(options: PaneCloseOptions): CloseProvider | undefined {
|
|
55
|
+
const env = options.env ?? process.env;
|
|
56
|
+
if ((options.platform ?? process.platform) !== 'linux' || env.HERDR_ENV !== '1' || !env.HERDR_PANE_ID) return;
|
|
57
|
+
const run = options.run ?? exec;
|
|
58
|
+
const processIdentity = options.processIdentity ?? linuxProcessIdentity;
|
|
59
|
+
const verifySession = options.verifySession ?? verifySessionHeader;
|
|
60
|
+
const ownPid = options.pid ?? process.pid;
|
|
61
|
+
const verificationMs = Math.max(0, Math.min(2000, options.verificationMs ?? 1500));
|
|
62
|
+
async function query(args: string[]): Promise<any> {
|
|
63
|
+
const output = await run('herdr', args);
|
|
64
|
+
if (Buffer.byteLength(output) > 1024 * 1024) throw rejected();
|
|
65
|
+
return JSON.parse(output)?.result;
|
|
66
|
+
}
|
|
67
|
+
async function caller(): Promise<any> {
|
|
68
|
+
const pane = (await query(['pane', 'current', '--current']))?.pane;
|
|
69
|
+
if (!pane || typeof pane.pane_id !== 'string' || typeof pane.workspace_id !== 'string' || typeof pane.terminal_id !== 'string') throw rejected();
|
|
70
|
+
return pane;
|
|
71
|
+
}
|
|
72
|
+
async function verifyPane(pane: any, identity: WorkerPaneIdentity): Promise<void> {
|
|
73
|
+
if (!pane || pane.pane_id !== identity.paneId || pane.workspace_id !== identity.workspaceId ||
|
|
74
|
+
pane.terminal_id !== identity.terminalId || pane.agent !== 'pi' ||
|
|
75
|
+
pane.agent_session?.source !== 'herdr:pi' || pane.agent_session?.kind !== 'path' ||
|
|
76
|
+
typeof pane.agent_session.value !== 'string' || !path.isAbsolute(identity.sessionFile) ||
|
|
77
|
+
!Number.isSafeInteger(identity.pid) || identity.pid <= 0 || !/^[0-9]+$/.test(identity.processStart)) throw rejected();
|
|
78
|
+
if (await realpath(pane.agent_session.value) !== await realpath(identity.sessionFile)) throw rejected();
|
|
79
|
+
await verifySession(identity.sessionFile, identity.sessionId);
|
|
80
|
+
const info = (await query(['pane', 'process-info', '--pane', identity.paneId]))?.process_info;
|
|
81
|
+
if (info?.pane_id !== identity.paneId || !Array.isArray(info.foreground_processes) ||
|
|
82
|
+
!info.foreground_processes.some((p: { pid?: number }) => p.pid === identity.pid) ||
|
|
83
|
+
!alive(await processIdentity(identity.pid), identity.processStart)) throw rejected();
|
|
84
|
+
}
|
|
85
|
+
async function inspect(identity: WorkerPaneIdentity, expectedSessionId: string, assertCurrent: () => void): Promise<void> {
|
|
86
|
+
assertCurrent();
|
|
87
|
+
if (identity.sessionId !== expectedSessionId || expectedSessionId === options.sessionId() || identity.pid === ownPid) throw rejected();
|
|
88
|
+
const own = await caller();
|
|
89
|
+
assertCurrent();
|
|
90
|
+
if (own.pane_id === identity.paneId || own.workspace_id !== identity.workspaceId) throw rejected();
|
|
91
|
+
await verifyPane((await query(['pane', 'get', identity.paneId]))?.pane, identity);
|
|
92
|
+
assertCurrent();
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
async getIdentity() {
|
|
96
|
+
try {
|
|
97
|
+
const sessionId = options.sessionId(), sessionFile = options.sessionFile();
|
|
98
|
+
if (!sessionFile) throw rejected();
|
|
99
|
+
const pane = await caller();
|
|
100
|
+
const process = await processIdentity(ownPid);
|
|
101
|
+
if (!process || !alive(process, process.start)) throw rejected();
|
|
102
|
+
const identity: WorkerPaneIdentity = { workspaceId: pane.workspace_id, paneId: pane.pane_id,
|
|
103
|
+
terminalId: pane.terminal_id, sessionId, sessionFile, pid: ownPid, processStart: process.start };
|
|
104
|
+
await verifyPane(pane, identity);
|
|
105
|
+
if (options.sessionId() !== sessionId || options.sessionFile() !== sessionFile) throw rejected();
|
|
106
|
+
return identity;
|
|
107
|
+
} catch { throw rejected(); }
|
|
108
|
+
},
|
|
109
|
+
async inspect(identity, expectedSessionId, assertCurrent) {
|
|
110
|
+
try { await inspect(identity, expectedSessionId, assertCurrent); } catch { throw rejected(); }
|
|
111
|
+
},
|
|
112
|
+
async close(identity, expectedSessionId, assertCurrent, beforeSubmit) {
|
|
113
|
+
try { await inspect(identity, expectedSessionId, assertCurrent); } catch { throw rejected(); }
|
|
114
|
+
assertCurrent();
|
|
115
|
+
// Revalidate worker settlement only AFTER potentially slow ownership reads.
|
|
116
|
+
// Herdr still has no atomic cross-process identity/idle compare-and-close.
|
|
117
|
+
await beforeSubmit();
|
|
118
|
+
assertCurrent();
|
|
119
|
+
// Exactly one explicit pane target. No tab/workspace close, retry, or force kill.
|
|
120
|
+
// A CLI timeout can mean the mutation happened: caller must persist uncertainty.
|
|
121
|
+
try { await run('herdr', ['pane', 'close', identity.paneId]); }
|
|
122
|
+
catch { throw new Error('PiIntercom: pane close outcome uncertain; inspect before any retry.'); }
|
|
123
|
+
let paneClosed = false, workerExited = false;
|
|
124
|
+
try {
|
|
125
|
+
const panes = (await query(['pane', 'list', '--workspace', identity.workspaceId]))?.panes;
|
|
126
|
+
paneClosed = Array.isArray(panes) && panes.every(p => typeof p?.pane_id === 'string') &&
|
|
127
|
+
!panes.some(p => p.pane_id === identity.paneId);
|
|
128
|
+
const deadline = Date.now() + verificationMs;
|
|
129
|
+
do {
|
|
130
|
+
workerExited = !alive(await processIdentity(identity.pid), identity.processStart);
|
|
131
|
+
if (workerExited || Date.now() >= deadline) break;
|
|
132
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
133
|
+
} while (true);
|
|
134
|
+
} catch { /* Verification failure is uncertainty, never an automatic retry. */ }
|
|
135
|
+
return { paneClosed, workerExited };
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/reminders.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export const REMINDER_INTERVAL_MS = 5 * 60 * 1000;
|
|
2
|
+
export const REMINDER_TEXT = 'Intercom reminder: Review outstanding delegated work and worker reports. If follow-up is needed, take the next appropriate action. Do not assume workers have finished or stopped.\n\nIf there is genuinely nothing to follow up on, call intercom_stop_reminders. Otherwise, reminders continue every five minutes; a new worker call resets the timer.\n\nThis reminder does not authorize new work, retries, worker closure, or publication.';
|
|
3
|
+
|
|
4
|
+
export interface ReminderClock {
|
|
5
|
+
schedule(callback: () => void, milliseconds: number): () => void;
|
|
6
|
+
}
|
|
7
|
+
const clock: ReminderClock = {
|
|
8
|
+
schedule(callback, milliseconds) {
|
|
9
|
+
const timer = setTimeout(callback, milliseconds);
|
|
10
|
+
timer.unref();
|
|
11
|
+
return () => clearTimeout(timer);
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
interface ReminderHost {
|
|
15
|
+
isCoordinator(): Promise<boolean>;
|
|
16
|
+
busy(): boolean;
|
|
17
|
+
deliver(text: string): void;
|
|
18
|
+
}
|
|
19
|
+
/** Ephemeral reminder only: never worker actions, network retries or a backlog. */
|
|
20
|
+
export class CoordinatorReminders {
|
|
21
|
+
private generation = 0;
|
|
22
|
+
private tick = 0;
|
|
23
|
+
private checking = false;
|
|
24
|
+
private disposed = false;
|
|
25
|
+
private cancel?: () => void;
|
|
26
|
+
private detachOwner?: () => void;
|
|
27
|
+
constructor(private readonly host: ReminderHost, private readonly timer: ReminderClock = clock) {}
|
|
28
|
+
arm(signal?: AbortSignal): void {
|
|
29
|
+
if (this.disposed || signal?.aborted) return;
|
|
30
|
+
this.stop();
|
|
31
|
+
const generation = this.generation;
|
|
32
|
+
if (signal) {
|
|
33
|
+
const abort = () => { if (this.generation === generation) this.stop(); };
|
|
34
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
35
|
+
this.detachOwner = () => signal.removeEventListener('abort', abort);
|
|
36
|
+
}
|
|
37
|
+
this.schedule(generation);
|
|
38
|
+
}
|
|
39
|
+
stop(): void {
|
|
40
|
+
this.generation++; this.tick++;
|
|
41
|
+
this.cancel?.(); this.cancel = undefined;
|
|
42
|
+
this.detachOwner?.(); this.detachOwner = undefined;
|
|
43
|
+
}
|
|
44
|
+
dispose(): void { this.stop(); this.disposed = true; }
|
|
45
|
+
private current(generation: number, tick: number): boolean {
|
|
46
|
+
return !this.disposed && this.generation === generation && this.tick === tick;
|
|
47
|
+
}
|
|
48
|
+
private schedule(generation: number): void {
|
|
49
|
+
this.cancel = this.timer.schedule(() => {
|
|
50
|
+
if (this.disposed || generation !== this.generation) return;
|
|
51
|
+
this.cancel = undefined;
|
|
52
|
+
const tick = ++this.tick;
|
|
53
|
+
this.schedule(generation);
|
|
54
|
+
// A slow role read never creates concurrent reads or a queued reminder.
|
|
55
|
+
// Advancing tick also invalidates any read from the preceding interval.
|
|
56
|
+
if (this.checking) return;
|
|
57
|
+
this.checking = true;
|
|
58
|
+
void (async () => {
|
|
59
|
+
try {
|
|
60
|
+
const coordinator = await this.host.isCoordinator();
|
|
61
|
+
if (!this.current(generation, tick)) return;
|
|
62
|
+
if (!coordinator) { this.stop(); return; }
|
|
63
|
+
if (!this.host.busy()) this.host.deliver(REMINDER_TEXT);
|
|
64
|
+
} catch { /* Unknown role/read/delivery outcome: no blind immediate replay. */ }
|
|
65
|
+
finally { this.checking = false; }
|
|
66
|
+
})();
|
|
67
|
+
}, REMINDER_INTERVAL_MS);
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { realpath } from 'node:fs/promises';
|
|
3
4
|
import { LocalObserver, LOG_LIMITS, observationError, type Observer, type EventType, type EventMetadata } from './observability.js';
|
|
4
|
-
import { ConfigStore, coordinator, directory, fail, named, port, requireCoordinator, type Agent, type Config } from './config.js';
|
|
5
|
+
import { ConfigStore, coordinator, directory, fail, named, port, requireCoordinator, text, validateConfig, type Agent, type Config } from './config.js';
|
|
5
6
|
import { listen, send, reportPayload, type Endpoint, type Envelope, type Kind } from './transport.js';
|
|
6
7
|
import { saveWorkerReport } from './reports.js';
|
|
7
8
|
import { readObservationSnapshot } from './snapshot.js';
|
|
8
9
|
import { workerStatusPage } from './worker-status.js';
|
|
9
10
|
import { probeWorker, probeWorkers } from './connections.js';
|
|
11
|
+
import { HandoffWorkflow, HANDOFF_KINDS, fencedClose, type CloseProvider, type HandoffKind } from './handoff.js';
|
|
12
|
+
import { CoordinatorReminders, type ReminderClock } from './reminders.js';
|
|
10
13
|
|
|
11
|
-
export const UNSUPPORTED_CANCELLATION = 'Unsupported Pi host: stop_worker and
|
|
14
|
+
export const UNSUPPORTED_CANCELLATION = 'Unsupported Pi host: stop_worker and legacy graceful-close control are disabled. Pi 0.84.4 extension abort does not cancel retry backoff/continuations; graceful shutdown cannot guarantee no queued work restarts. No cancellation or shutdown was performed. close_worker uses a separate supported-provider owned-pane closure contract, not graceful cancellation. See references/implementation-blocker.md. A verified supported host API is required for graceful cancellation (no version-only override).';
|
|
12
15
|
export interface Host {
|
|
13
16
|
sessionId(): string;
|
|
14
17
|
cwd: string;
|
|
@@ -17,7 +20,7 @@ export interface Host {
|
|
|
17
20
|
setName(name: string): Promise<void>;
|
|
18
21
|
notify(text: string): void;
|
|
19
22
|
}
|
|
20
|
-
export interface LaunchRequest { multiplexer: 'herdr' | 'none'; cwd: string; sessionId?: string }
|
|
23
|
+
export interface LaunchRequest { multiplexer: 'herdr' | 'none'; cwd: string; workspaceRoot: string; sessionId?: string }
|
|
21
24
|
export interface RuntimeOptions {
|
|
22
25
|
launch(request: LaunchRequest): Promise<unknown>;
|
|
23
26
|
listen?: typeof listen;
|
|
@@ -25,6 +28,12 @@ export interface RuntimeOptions {
|
|
|
25
28
|
probe?: typeof probeWorker;
|
|
26
29
|
store?: (root: string) => ConfigStore;
|
|
27
30
|
observe?: (root: string, sessionId: string) => Observer;
|
|
31
|
+
closeProvider?: CloseProvider;
|
|
32
|
+
/** Test seam; production defaults to 120 seconds, values above that are capped. */
|
|
33
|
+
closeTimeoutMs?: number;
|
|
34
|
+
reminderClock?: ReminderClock;
|
|
35
|
+
/** Explicit launcher-provided root must equal canonical cwd; no parent-root exception. */
|
|
36
|
+
workspaceRoot?: string;
|
|
28
37
|
}
|
|
29
38
|
export class Intercom {
|
|
30
39
|
store!: ConfigStore;
|
|
@@ -32,6 +41,8 @@ export class Intercom {
|
|
|
32
41
|
private active = false;
|
|
33
42
|
private generation = 0;
|
|
34
43
|
private initialId = '';
|
|
44
|
+
private configuredRoot?: string;
|
|
45
|
+
private configuredFile?: string;
|
|
35
46
|
responsibility?: Agent;
|
|
36
47
|
private observer?: Observer;
|
|
37
48
|
private closing?: Promise<void>;
|
|
@@ -39,6 +50,12 @@ export class Intercom {
|
|
|
39
50
|
private pendingReports = 0;
|
|
40
51
|
private probeController = new AbortController();
|
|
41
52
|
private resumeFences = new Map<string, { inFlight: boolean; submitted: boolean; announced: boolean }>();
|
|
53
|
+
private handoff?: HandoffWorkflow;
|
|
54
|
+
private reminders?: CoordinatorReminders;
|
|
55
|
+
private reminderRevision = 0;
|
|
56
|
+
private reminderInvocation = 0;
|
|
57
|
+
workerStarted(): void { this.handoff?.workerStarted(); }
|
|
58
|
+
workerSettled(successfulToolCallIds: ReadonlySet<string>): void { this.handoff?.workerSettled(successfulToolCallIds); }
|
|
42
59
|
private captureObserver(): (event: EventType, metadata?: EventMetadata) => void {
|
|
43
60
|
const observer = this.observer;
|
|
44
61
|
return (event, metadata = {}) => {
|
|
@@ -50,6 +67,7 @@ export class Intercom {
|
|
|
50
67
|
private id(): string {
|
|
51
68
|
const id = this.host.sessionId();
|
|
52
69
|
if (!this.active || id !== this.initialId) fail('session is inactive or replaced');
|
|
70
|
+
if (this.configuredRoot !== undefined && (this.store.root !== this.configuredRoot || this.store.file !== this.configuredFile)) fail('config store boundary changed');
|
|
53
71
|
return id;
|
|
54
72
|
}
|
|
55
73
|
private validity(): () => void {
|
|
@@ -64,19 +82,27 @@ export class Intercom {
|
|
|
64
82
|
if (this.closing) await this.closing;
|
|
65
83
|
if (this.active) fail('runtime already active');
|
|
66
84
|
this.initialId = this.host.sessionId(); this.active = true;
|
|
85
|
+
this.configuredRoot = undefined; this.configuredFile = undefined;
|
|
67
86
|
this.probeController = new AbortController();
|
|
68
87
|
this.resumeFences.clear();
|
|
69
88
|
const generation = ++this.generation, assertValid = this.validity();
|
|
70
89
|
let record = this.captureObserver();
|
|
71
90
|
try {
|
|
72
|
-
const discovered =
|
|
91
|
+
const discovered = this.options.workspaceRoot !== undefined
|
|
92
|
+
? await ConfigStore.workspace(this.host.cwd, this.options.workspaceRoot)
|
|
93
|
+
: await ConfigStore.discover(this.host.cwd);
|
|
94
|
+
assertValid();
|
|
95
|
+
const root = discovered?.root ?? await realpath(this.host.cwd);
|
|
96
|
+
assertValid();
|
|
97
|
+
this.store = this.options.store?.(root) ?? discovered ?? new ConfigStore(root);
|
|
98
|
+
if (this.store.root !== root || this.store.file !== path.join(root, '.pi-intercom', 'config.json')) fail('config store must retain validated session root');
|
|
99
|
+
this.configuredRoot = root; this.configuredFile = this.store.file;
|
|
100
|
+
await this.store.validateRoot(root); assertValid();
|
|
101
|
+
const previous = discovered ? await this.store.read() : undefined;
|
|
73
102
|
assertValid();
|
|
74
|
-
this.store = this.options.store?.(discovered?.root ?? this.host.cwd) ?? discovered ?? new ConfigStore(this.host.cwd);
|
|
75
103
|
try { this.observer = this.options.observe ? this.options.observe(this.store.root, this.initialId) : new LocalObserver(this.store.root, this.initialId); } catch { /* Logging is optional on failure. */ }
|
|
76
104
|
record = this.captureObserver();
|
|
77
105
|
record('runtime.starting');
|
|
78
|
-
const previous = discovered ? await this.store.read() : undefined;
|
|
79
|
-
assertValid();
|
|
80
106
|
const own = previous?.agents.find(a => a.sessionId === this.id());
|
|
81
107
|
const endpoint = await (this.options.listen ?? listen)(own?.port, m => { assertValid(); return this.receive(m); }, () => {
|
|
82
108
|
assertValid();
|
|
@@ -91,7 +117,28 @@ export class Intercom {
|
|
|
91
117
|
const config = await this.store.read();
|
|
92
118
|
assertValid();
|
|
93
119
|
const me = config.agents.find(a => a.sessionId === this.id());
|
|
120
|
+
this.reminders = new CoordinatorReminders({
|
|
121
|
+
isCoordinator: async () => { const fresh = await this.state(); assertValid(); return fresh.me?.coordinator === true; },
|
|
122
|
+
busy: () => this.host.busy(),
|
|
123
|
+
deliver: message => { assertValid(); this.host.deliver(message, false); },
|
|
124
|
+
}, this.options.reminderClock);
|
|
125
|
+
this.handoff = new HandoffWorkflow({
|
|
126
|
+
state: () => this.state(), assertCurrent: assertValid,
|
|
127
|
+
update: async (mutate, guard) => {
|
|
128
|
+
await this.store.update(this.initialId, mutate, () => { assertValid(); guard?.(); });
|
|
129
|
+
},
|
|
130
|
+
send: async (sessionId, kind, payload) => {
|
|
131
|
+
const state = await this.state(); assertValid();
|
|
132
|
+
const target = state.config.agents.find(a => a.sessionId === sessionId);
|
|
133
|
+
if (!target) fail('handoff recipient no longer configured');
|
|
134
|
+
await (this.options.send ?? send)(target.port, { version: 1, kind, from: state.id, to: sessionId, payload, correlationId: randomUUID() });
|
|
135
|
+
assertValid();
|
|
136
|
+
},
|
|
137
|
+
deliver: message => { assertValid(); this.host.deliver(message, this.host.busy()); },
|
|
138
|
+
busy: () => this.host.busy(), provider: this.options.closeProvider, timeoutMs: this.options.closeTimeoutMs,
|
|
139
|
+
});
|
|
94
140
|
if (me?.coordinator) {
|
|
141
|
+
await this.handoff.recover(); assertValid();
|
|
95
142
|
try { this.observer?.maintain?.(); } catch { /* No cleanup failure affects readiness. */ }
|
|
96
143
|
await this.store.update(this.id(), c => { requireCoordinator(c, this.id()).port = this.endpoint!.port; }, assertValid);
|
|
97
144
|
assertValid();
|
|
@@ -128,6 +175,9 @@ export class Intercom {
|
|
|
128
175
|
const observer = this.observer; this.observer = undefined;
|
|
129
176
|
this.active = false; this.generation++;
|
|
130
177
|
this.probeController.abort();
|
|
178
|
+
this.handoff?.dispose(); this.handoff = undefined;
|
|
179
|
+
this.reminderRevision = ++this.reminderInvocation;
|
|
180
|
+
this.reminders?.dispose(); this.reminders = undefined;
|
|
131
181
|
const endpoint = this.endpoint; this.endpoint = undefined;
|
|
132
182
|
this.responsibility = undefined;
|
|
133
183
|
const closing = (async () => {
|
|
@@ -170,7 +220,8 @@ export class Intercom {
|
|
|
170
220
|
assertValid();
|
|
171
221
|
if (!me) fail('worker not configured; configure_worker must run before reload_worker');
|
|
172
222
|
this.responsibility = { ...me };
|
|
173
|
-
|
|
223
|
+
// Coordinator is a routing identity, not ownership of the user's Pi/tab title.
|
|
224
|
+
if (!me.coordinator) await this.host.setName(me.name);
|
|
174
225
|
assertValid();
|
|
175
226
|
record('config.reloaded', { role: me.coordinator ? 'coordinator' : 'worker' });
|
|
176
227
|
this.host.notify(`Intercom responsibility loaded for ${me.name}; no work turn started.`);
|
|
@@ -181,6 +232,7 @@ export class Intercom {
|
|
|
181
232
|
const { id, config } = await this.state();
|
|
182
233
|
assertValid();
|
|
183
234
|
const target = named(config, to), correlationId = randomUUID();
|
|
235
|
+
if (['message', 'reload'].includes(kind) && (this.handoff?.isClosing(target.sessionId) || fencedClose(target.closeJob))) fail('worker close is active or uncertain; new Intercom work is fenced');
|
|
184
236
|
const metadata = { peerSessionId: target.sessionId, peerName: target.name, correlationId, kind };
|
|
185
237
|
record('transport.send', { ...metadata, outcome: 'attempted' });
|
|
186
238
|
try {
|
|
@@ -232,6 +284,10 @@ export class Intercom {
|
|
|
232
284
|
throw error;
|
|
233
285
|
}
|
|
234
286
|
};
|
|
287
|
+
if (HANDOFF_KINDS.includes(message.kind)) {
|
|
288
|
+
if (!this.handoff || !me || (!me.coordinator && !this.responsibility)) fail('handoff requires a configured, responsibility-loaded worker');
|
|
289
|
+
await this.handoff.receive(message.kind as HandoffKind, message.from, message.payload); return;
|
|
290
|
+
}
|
|
235
291
|
if (message.kind === 'registration' || message.kind === 'status') {
|
|
236
292
|
requireCoordinator(config, id);
|
|
237
293
|
if (sender?.coordinator) fail('coordinator cannot report as worker');
|
|
@@ -281,12 +337,16 @@ export class Intercom {
|
|
|
281
337
|
if (message.kind === 'message') {
|
|
282
338
|
if (!sender || !me) fail('agent messaging requires configured sender and recipient; anonymous permissions TODO');
|
|
283
339
|
if (!this.responsibility) fail('worker must load responsibility before receiving work');
|
|
340
|
+
if (this.handoff?.workerClosing || fencedClose(me.closeJob)) fail('worker close is active or uncertain; new Intercom work is fenced');
|
|
284
341
|
notify('agent message', message.payload.message as string); return;
|
|
285
342
|
}
|
|
286
343
|
if (!sender?.coordinator) fail('control requires current coordinator sender session ID');
|
|
287
344
|
if (me?.coordinator) fail('worker control cannot target coordinator');
|
|
288
345
|
if (message.kind === 'stop' || message.kind === 'close') fail(UNSUPPORTED_CANCELLATION);
|
|
289
|
-
if (message.kind === 'reload') {
|
|
346
|
+
if (message.kind === 'reload') {
|
|
347
|
+
if (this.handoff?.workerClosing || fencedClose(me?.closeJob)) fail('worker close is active or uncertain; reload is fenced');
|
|
348
|
+
await this.reload(); return;
|
|
349
|
+
}
|
|
290
350
|
if (message.kind === 'request_status') {
|
|
291
351
|
// Independent one-way report, never a synchronous status in the HTTP response.
|
|
292
352
|
setImmediate(() => {
|
|
@@ -300,7 +360,17 @@ export class Intercom {
|
|
|
300
360
|
}
|
|
301
361
|
fail('unsupported control');
|
|
302
362
|
}
|
|
303
|
-
async tool(operation: string, args: Record<string, unknown
|
|
363
|
+
async tool(operation: string, args: Record<string, unknown>, toolCallId?: string, reminderSignal?: AbortSignal): Promise<unknown> {
|
|
364
|
+
// Reserve invocation order before state/directory awaits, but only VALID
|
|
365
|
+
// reminder actions advance the accepted revision. Invalid/nonworker calls
|
|
366
|
+
// must not supersede a pending stop or suppress an older valid preflight.
|
|
367
|
+
const invocation = ++this.reminderInvocation;
|
|
368
|
+
const armReminder = () => {
|
|
369
|
+
if (invocation >= this.reminderRevision && !reminderSignal?.aborted) {
|
|
370
|
+
this.reminderRevision = invocation;
|
|
371
|
+
this.reminders?.arm(reminderSignal);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
304
374
|
const record = this.captureObserver();
|
|
305
375
|
const assertValid = this.validity();
|
|
306
376
|
const { id, config, me } = await this.state();
|
|
@@ -322,6 +392,10 @@ export class Intercom {
|
|
|
322
392
|
if (!me) fail('anonymous intercom_list permissions unresolved (TODO); no coordinator privileges');
|
|
323
393
|
return { ...config, agents: config.agents.map(({ dashboardPort: _retired, ...agent }) => agent) };
|
|
324
394
|
}
|
|
395
|
+
if (operation === 'report_handoff') {
|
|
396
|
+
if (!me || me.coordinator || !this.responsibility || !this.handoff) fail('report_handoff requires a configured, responsibility-loaded worker');
|
|
397
|
+
return this.handoff.report(args.jobId, args.summary, toolCallId);
|
|
398
|
+
}
|
|
325
399
|
if (operation === 'report_status') { await this.report(); return { accepted: true }; }
|
|
326
400
|
if (operation === 'report_work') {
|
|
327
401
|
if (!me || me.coordinator || !this.responsibility) fail('report_work requires a configured, responsibility-loaded worker');
|
|
@@ -331,12 +405,37 @@ export class Intercom {
|
|
|
331
405
|
}
|
|
332
406
|
if (operation === 'send') {
|
|
333
407
|
if (!me || !this.responsibility) fail('anonymous/unloaded worker cannot send agent messages (permissions TODO)');
|
|
408
|
+
const recipient = named(config, args.to as string);
|
|
409
|
+
text(args.message, 'message', 48000);
|
|
410
|
+
if (this.handoff?.workerClosing || fencedClose(me.closeJob) || this.handoff?.isClosing(recipient.sessionId) || fencedClose(recipient.closeJob)) fail('worker close is active or uncertain; new Intercom work is fenced');
|
|
411
|
+
if (me.coordinator && !recipient.coordinator) armReminder();
|
|
334
412
|
await this.transmit(args.to as string, 'message', { message: args.message }); return { accepted: true, completion: 'not awaited' };
|
|
335
413
|
}
|
|
336
414
|
requireCoordinator(config, id);
|
|
337
|
-
if (operation === '
|
|
415
|
+
if (operation === 'stop_reminders') {
|
|
416
|
+
const current = invocation >= this.reminderRevision;
|
|
417
|
+
if (current) { this.reminderRevision = invocation; this.reminders?.stop(); }
|
|
418
|
+
return { stopped: current };
|
|
419
|
+
}
|
|
420
|
+
if (operation === 'stop_worker') fail(UNSUPPORTED_CANCELLATION);
|
|
338
421
|
if (operation === 'configure_worker') {
|
|
339
|
-
|
|
422
|
+
const assertConfigurable = () => {
|
|
423
|
+
assertValid();
|
|
424
|
+
if (this.handoff?.isClosing(args.sessionId as string) || this.resumeFences.has(args.sessionId as string)) fail('worker lifecycle operation is in flight; configuration is fenced');
|
|
425
|
+
};
|
|
426
|
+
assertConfigurable();
|
|
427
|
+
// Validate the candidate without writing, so invalid input never arms a
|
|
428
|
+
// reminder; persistence failures after valid initiation still do.
|
|
429
|
+
const relative = await directory(this.store.root, args.projectDirectory as string);
|
|
430
|
+
assertConfigurable();
|
|
431
|
+
const candidate: Agent = { sessionId: args.sessionId as string, name: args.name as string, description: args.description as string,
|
|
432
|
+
port: args.port as number, projectDirectory: relative, coordinator: false };
|
|
433
|
+
if (candidate.sessionId === id) fail('cannot configure coordinator as worker');
|
|
434
|
+
const previous = config.agents.find(a => a.sessionId === candidate.sessionId);
|
|
435
|
+
if (fencedClose(previous?.closeJob)) fail('worker close is active or uncertain; configuration is fenced');
|
|
436
|
+
validateConfig({ ...config, agents: [...config.agents.filter(a => a.sessionId !== candidate.sessionId), candidate] });
|
|
437
|
+
armReminder();
|
|
438
|
+
await this.store.configure(id, args as unknown as Omit<Agent, 'coordinator'>, assertConfigurable);
|
|
340
439
|
record('config.changed', { operation: 'configure_worker', peerSessionId: args.sessionId as string, peerName: args.name as string, outcome: 'written' });
|
|
341
440
|
return { written: true, reloaded: false };
|
|
342
441
|
}
|
|
@@ -347,34 +446,51 @@ export class Intercom {
|
|
|
347
446
|
if (operation === 'create_worker') {
|
|
348
447
|
const relative = await directory(this.store.root, (args.projectDirectory as string | undefined) ?? '.');
|
|
349
448
|
assertValid();
|
|
350
|
-
|
|
449
|
+
if (relative !== '.') fail('worker working directory must equal the config root; subdirectory launches would read a forbidden parent config');
|
|
450
|
+
armReminder();
|
|
451
|
+
return this.launchObserved({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative), workspaceRoot: this.store.root });
|
|
351
452
|
}
|
|
352
453
|
const target = named(config, args.to as string);
|
|
353
454
|
if (target.coordinator) fail('operation requires a worker target');
|
|
455
|
+
if (operation === 'close_worker') {
|
|
456
|
+
if (!this.options.closeProvider || !this.handoff || config.multiplexer !== 'herdr') fail('Owned-pane close requires supported Linux Herdr provider; no closure attempted');
|
|
457
|
+
if (this.resumeFences.has(target.sessionId)) fail('resume is in flight; close is fenced');
|
|
458
|
+
if (fencedClose(target.closeJob) && !this.handoff.isClosing(target.sessionId)) fail('worker close is active or uncertain; close is fenced');
|
|
459
|
+
armReminder();
|
|
460
|
+
return { job: await this.handoff.request(target), completion: 'not awaited', contract: 'best-effort owned-pane closure; not all-child-process termination' };
|
|
461
|
+
}
|
|
462
|
+
if (['reload_worker', 'resume_worker', 'remove_worker'].includes(operation) && (this.handoff?.isClosing(target.sessionId) || fencedClose(target.closeJob))) fail('worker close is active or uncertain; operation is fenced');
|
|
354
463
|
if (operation === 'reload_worker' || operation === 'request_status') {
|
|
464
|
+
armReminder();
|
|
355
465
|
await this.transmit(target.name, operation === 'reload_worker' ? 'reload' : 'request_status');
|
|
356
466
|
return { accepted: true, completion: 'not awaited' };
|
|
357
467
|
}
|
|
358
468
|
if (operation === 'resume_worker') {
|
|
359
469
|
if (args.confirmClosed !== true) fail('Resume requires explicit user confirmation that the previous worker session is closed (confirmClosed:true). Disconnected alone is not proof of closure.');
|
|
360
470
|
if (this.resumeFences.has(target.sessionId)) fail('Resume already in flight or awaiting worker status; outcome may be uncertain. Inspect the worker before any retry; no automatic relaunch.');
|
|
471
|
+
const relative = await directory(this.store.root, target.projectDirectory);
|
|
472
|
+
assertValid();
|
|
473
|
+
if (relative !== '.') fail('worker working directory must equal the config root; subdirectory resumes would read a forbidden parent config');
|
|
474
|
+
const cwd = path.resolve(this.store.root, relative);
|
|
475
|
+
// Directory validation is read-only; recheck admission after that await.
|
|
476
|
+
if (this.resumeFences.has(target.sessionId)) fail('Resume already in flight or awaiting worker status; outcome may be uncertain. Inspect the worker before any retry; no automatic relaunch.');
|
|
477
|
+
if (this.handoff?.isClosing(target.sessionId)) fail('worker close is active or uncertain; resume is fenced');
|
|
361
478
|
const fence = { inFlight: true, submitted: false, announced: false };
|
|
362
479
|
this.resumeFences.set(target.sessionId, fence);
|
|
480
|
+
armReminder();
|
|
363
481
|
let completed = false;
|
|
364
482
|
try {
|
|
365
483
|
const connection = await (this.options.probe ?? probeWorker)(target, { signal: this.probeController.signal });
|
|
366
484
|
assertValid();
|
|
367
485
|
if (connection.state === 'connected') fail('Worker endpoint is still connected; refusing to resume the same session twice.');
|
|
368
|
-
const relative = await directory(this.store.root, target.projectDirectory);
|
|
369
|
-
assertValid();
|
|
370
|
-
const cwd = path.resolve(this.store.root, relative);
|
|
371
486
|
const fresh = await this.state();
|
|
372
487
|
assertValid();
|
|
373
488
|
requireCoordinator(fresh.config, id);
|
|
374
489
|
const current = fresh.config.agents.find(agent => agent.sessionId === target.sessionId && !agent.coordinator);
|
|
375
490
|
if (!current || current.port !== target.port || current.projectDirectory !== target.projectDirectory) fail('Worker configuration changed during resume check; no launch submitted.');
|
|
491
|
+
if (this.handoff?.isClosing(target.sessionId) || fencedClose(current.closeJob)) fail('worker close is active or uncertain; resume is fenced');
|
|
376
492
|
fence.submitted = true;
|
|
377
|
-
const result = await this.launchObserved({ multiplexer: fresh.config.multiplexer, cwd, sessionId: current.sessionId });
|
|
493
|
+
const result = await this.launchObserved({ multiplexer: fresh.config.multiplexer, cwd, workspaceRoot: this.store.root, sessionId: current.sessionId });
|
|
378
494
|
completed = true;
|
|
379
495
|
return result;
|
|
380
496
|
} finally {
|
|
@@ -384,7 +500,11 @@ export class Intercom {
|
|
|
384
500
|
}
|
|
385
501
|
if (operation === 'remove_worker') {
|
|
386
502
|
// No live process claim: the caller must explicitly ensure closure first.
|
|
387
|
-
await this.store.update(id, c => {
|
|
503
|
+
await this.store.update(id, c => {
|
|
504
|
+
const current = c.agents.find(a => a.sessionId === target.sessionId);
|
|
505
|
+
if (this.handoff?.isClosing(target.sessionId) || this.resumeFences.has(target.sessionId) || fencedClose(current?.closeJob)) fail('worker lifecycle operation is active or uncertain; removal is fenced');
|
|
506
|
+
c.agents = c.agents.filter(a => a.sessionId !== target.sessionId);
|
|
507
|
+
}, assertValid);
|
|
388
508
|
record('config.changed', { operation: 'remove_worker', peerSessionId: target.sessionId, peerName: target.name, outcome: 'removed' });
|
|
389
509
|
return { removed: true, processStopped: false, sessionDeleted: false };
|
|
390
510
|
}
|
package/src/snapshot.ts
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import { validateConfig, type Config } from './config.js';
|
|
5
5
|
import { readWorkerReports, type WorkerReport } from './reports.js';
|
|
6
6
|
import type { WorkerConnection } from './connections.js';
|
|
7
|
+
import { validateHandoff, validateCloseJob, type Handoff, type CloseJob } from './handoff.js';
|
|
7
8
|
import { LOG_DIRECTORY, LOG_FILE_PATTERN, sanitizeObservation, type Observation } from './observability.js';
|
|
8
9
|
|
|
9
10
|
const CONFIG_BYTES = 1024 * 1024, TAIL_BYTES = 128 * 1024, TOTAL_BYTES = 2 * 1024 * 1024;
|
|
@@ -21,6 +22,26 @@ export interface ObservationSnapshot {
|
|
|
21
22
|
errors: string[];
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
/** Public saved context only; reject malformed/private protocol metadata. */
|
|
26
|
+
export function publicCloseMetadata(agent: { handoff?: unknown; closeJob?: unknown }): { handoff?: Handoff; closeJob?: CloseJob } {
|
|
27
|
+
const result: { handoff?: Handoff; closeJob?: CloseJob } = {};
|
|
28
|
+
try {
|
|
29
|
+
if (agent.handoff !== undefined) {
|
|
30
|
+
validateHandoff(agent.handoff);
|
|
31
|
+
const { version, summary, updatedAt, jobId } = agent.handoff;
|
|
32
|
+
result.handoff = { version, summary, updatedAt, jobId };
|
|
33
|
+
}
|
|
34
|
+
} catch { /* Invalid optional context is not public evidence. */ }
|
|
35
|
+
try {
|
|
36
|
+
if (agent.closeJob !== undefined) {
|
|
37
|
+
validateCloseJob(agent.closeJob);
|
|
38
|
+
const { jobId, state, createdAt, updatedAt, deadlineAt, reason } = agent.closeJob;
|
|
39
|
+
result.closeJob = { jobId, state, createdAt, updatedAt, deadlineAt, ...(reason ? { reason } : {}) };
|
|
40
|
+
}
|
|
41
|
+
} catch { /* Never expose arbitrary errors or private protocol fields. */ }
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
// Only fixed, extension-owned paths are read. Reject symlink/junction components
|
|
25
46
|
// rather than allowing a local log/config link to expose an unrelated file.
|
|
26
47
|
async function confined(root: string, relative: string): Promise<string> {
|
|
@@ -65,7 +86,7 @@ export async function readObservationSnapshot(root: string): Promise<Observation
|
|
|
65
86
|
result.truncated ||= config.agents.length > MAX_AGENTS;
|
|
66
87
|
result.config = { multiplexer: config.multiplexer, agents: config.agents.slice(0, MAX_AGENTS).map(a => ({
|
|
67
88
|
sessionId: a.sessionId, name: a.name, coordinator: a.coordinator, description: a.description,
|
|
68
|
-
projectDirectory: a.projectDirectory, port: a.port,
|
|
89
|
+
projectDirectory: a.projectDirectory, port: a.port, ...publicCloseMetadata(a),
|
|
69
90
|
})) };
|
|
70
91
|
} catch { result.errors.push('config_unavailable'); }
|
|
71
92
|
try {
|
package/src/transport.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
2
|
import { fail, port, text } from './config.js';
|
|
3
3
|
import type { ReportStatus } from './reports.js';
|
|
4
|
+
import { HANDOFF_KINDS, validateHandoffPayload, type HandoffKind } from './handoff.js';
|
|
4
5
|
|
|
5
6
|
export const BODY_LIMIT = 64 * 1024;
|
|
6
7
|
export const RECEIPT_TIMEOUT = 5000;
|
|
7
|
-
export type Kind = 'message' | 'report' | 'registration' | 'status' | 'request_status' | 'reload' | 'stop' | 'close';
|
|
8
|
+
export type Kind = 'message' | 'report' | 'registration' | 'status' | 'request_status' | 'reload' | 'stop' | 'close' | HandoffKind;
|
|
8
9
|
export interface Envelope {
|
|
9
10
|
version: 1;
|
|
10
11
|
kind: Kind;
|
|
@@ -23,12 +24,13 @@ export function reportPayload(payload: Record<string, unknown>): { status: Repor
|
|
|
23
24
|
}
|
|
24
25
|
export function envelope(value: unknown): Envelope {
|
|
25
26
|
const m = value as Envelope;
|
|
26
|
-
if (!m || m.version !== 1 || !['message', 'report', 'registration', 'status', 'request_status', 'reload', 'stop', 'close'].includes(m.kind)) fail('invalid wire schema/version/kind');
|
|
27
|
+
if (!m || m.version !== 1 || !['message', 'report', 'registration', 'status', 'request_status', 'reload', 'stop', 'close', ...HANDOFF_KINDS].includes(m.kind)) fail('invalid wire schema/version/kind');
|
|
27
28
|
text(m.from, 'sender sessionId', 256); text(m.to, 'recipient sessionId', 256);
|
|
28
29
|
if (m.correlationId !== undefined && (typeof m.correlationId !== 'string' || !/^[a-zA-Z0-9-]{1,128}$/.test(m.correlationId))) fail('invalid correlation ID');
|
|
29
30
|
if (!m.payload || Array.isArray(m.payload) || typeof m.payload !== 'object') fail('invalid payload');
|
|
30
31
|
if (m.kind === 'message') text(m.payload.message, 'message', 48000);
|
|
31
32
|
if (m.kind === 'report') reportPayload(m.payload);
|
|
33
|
+
if (HANDOFF_KINDS.includes(m.kind)) validateHandoffPayload(m.kind, m.payload);
|
|
32
34
|
if (m.kind === 'registration' || m.kind === 'status') port(m.payload.port);
|
|
33
35
|
if (m.kind === 'registration') text(m.payload.projectDirectory, 'projectDirectory');
|
|
34
36
|
if (m.kind === 'status' && typeof m.payload.busy !== 'boolean') fail('invalid busy flag');
|
package/src/worker-status.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { publicCloseMetadata, type ObservationSnapshot } from './snapshot.js';
|
|
2
2
|
import { currentConnection, sortWorkersByConnection } from './connections.js';
|
|
3
3
|
|
|
4
4
|
const labels: Record<string, string> = {
|
|
@@ -49,14 +49,21 @@ export function workerStatusPage(snapshot: ObservationSnapshot, options: { name?
|
|
|
49
49
|
}
|
|
50
50
|
const workers: Array<{ sessionId: string; [key: string]: unknown }> = [];
|
|
51
51
|
const result = { version: 1, source: 'local-observations', generatedAt: new Date(now).toISOString(),
|
|
52
|
-
statusMeaning: 'Activity is last observed, not task completion. Connectivity is a separate endpoint check, never proof of termination. Reports are self-reported, not approval.',
|
|
52
|
+
statusMeaning: 'Activity is last observed, not task completion. Connectivity is a separate endpoint check, never proof of termination. Reports are self-reported, not approval. Handoff is saved public context, not task completion; closeJob is workflow state, never proof that all child processes terminated.',
|
|
53
53
|
truncated: snapshot.truncated, errors: snapshot.errors, totalInSnapshot: roster.length, offset,
|
|
54
54
|
nextOffset: null as number | null, workers };
|
|
55
55
|
for (const agent of roster.slice(offset, offset + limit)) {
|
|
56
56
|
const report = snapshot.reports?.find(r => r.sessionId === agent.sessionId && r.status !== 'clear');
|
|
57
57
|
const reportAge = report ? now - Date.parse(report.updatedAt) : NaN;
|
|
58
|
+
const saved = publicCloseMetadata(agent);
|
|
59
|
+
const ageSeconds = (timestamp: string) => {
|
|
60
|
+
const age = now - Date.parse(timestamp);
|
|
61
|
+
return Number.isFinite(age) && age >= 0 ? Math.floor(age / 1000) : null;
|
|
62
|
+
};
|
|
58
63
|
const entry = { sessionId: agent.sessionId, name: agent.name, ...workerObservation(snapshot, agent.sessionId, now),
|
|
59
64
|
connection: currentConnection(agent.sessionId, snapshot.connections, now),
|
|
65
|
+
handoff: saved.handoff ? { ...saved.handoff, ageSeconds: ageSeconds(saved.handoff.updatedAt), selfReported: true } : null,
|
|
66
|
+
closeJob: saved.closeJob ? { ...saved.closeJob, ageSeconds: ageSeconds(saved.closeJob.updatedAt) } : null,
|
|
60
67
|
report: report ? { status: report.status, summary: report.summary, updatedAt: report.updatedAt,
|
|
61
68
|
ageSeconds: Number.isFinite(reportAge) && reportAge >= 0 ? Math.floor(reportAge / 1000) : null,
|
|
62
69
|
selfReported: true } : null };
|