pi-onlyne 0.8.0 → 0.9.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 +2 -2
- package/SPEC.md +18 -5
- package/dist/index.d.ts +25 -0
- package/dist/index.js +318 -114
- package/dist/onlyne.d.ts +33 -5
- package/dist/onlyne.js +48 -12
- package/dist/session.d.ts +82 -0
- package/dist/session.js +91 -0
- package/dist/swarm-slot.d.ts +1 -7
- package/dist/swarm-slot.js +21 -18
- package/dist/swarm.d.ts +54 -0
- package/dist/swarm.js +123 -17
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { join, resolve } from "node:path";
|
|
3
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { Type } from "typebox";
|
|
6
|
-
import { broadcast, connectDaemon, consumeEvent, loopback, markConsumed, sendWithRetry, shutdownDaemon, stopProcess, subscribe, swarmReady, swarmRecycled } from "./onlyne.js";
|
|
6
|
+
import { broadcast, connectDaemon, consumeEvent, loopback, markConsumed, sendWithRetry, sendSwarmReport, shutdownDaemon, stopProcess, subscribe, swarmBusy, swarmIdle, swarmReady, swarmRecycled } from "./onlyne.js";
|
|
7
7
|
import { inboundModeFor, loadConfig, saveConfig } from "./config.js";
|
|
8
8
|
import { findWorkspace } from "./workspace.js";
|
|
9
|
-
import { envTaskId, parseSwarmCtl, parseSwarmHeader, readSwarmEnabled, readSwarmModel, terminalHandle, treePathForWorkspace } from "./swarm.js";
|
|
9
|
+
import { envTaskId, hopActivityBody, isSwarmReportFrame, isSwarmTaskId, parseSwarmCtl, parseSwarmHeader, readSwarmEnabled, readSwarmModel, renderSwarmReport, selectHistoryDelivery, terminalHandle, treePathForWorkspace } from "./swarm.js";
|
|
10
10
|
import { SwarmSlot } from "./swarm-slot.js";
|
|
11
|
+
import { SwarmSessionState, restoreSwarmState } from "./session.js";
|
|
11
12
|
const swarmSlot = new SwarmSlot();
|
|
12
13
|
const state = { cwd: process.cwd(), workspace: null, watching: false, owner: "stopped", swarm: false };
|
|
13
14
|
let cachedCtx = undefined;
|
|
@@ -19,6 +20,122 @@ function consumeIfNotified(inbound) { if (state.workspace && inbound.messageId)
|
|
|
19
20
|
void markConsumed(state.workspace.socketPath, inbound.messageId).catch(() => { }); }
|
|
20
21
|
function clearReminder() { if (state.reminderTimer)
|
|
21
22
|
clearTimeout(state.reminderTimer); state.reminderTimer = undefined; }
|
|
23
|
+
function swarmOpId(kind, suffix) {
|
|
24
|
+
const taskId = state.swarmSession?.taskId ?? state.swarmTask?.taskId ?? state.swarmTaskKey ?? `session:${terminalHandle() || state.workspace?.root || "unknown"}`;
|
|
25
|
+
const generation = state.swarmSession?.generation ?? 0;
|
|
26
|
+
return `swarm:${taskId}:g${generation}:${kind}${suffix ? `:${suffix}` : ""}`;
|
|
27
|
+
}
|
|
28
|
+
async function awaitSubscriptionAck(socket, timeoutMs = 5_000) {
|
|
29
|
+
let timer;
|
|
30
|
+
try {
|
|
31
|
+
return await Promise.race([
|
|
32
|
+
socket.ack,
|
|
33
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("swarm subscribe_events ack timeout")), timeoutMs); }),
|
|
34
|
+
]);
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
if (timer)
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function awaitIntent(intent) { void Promise.resolve(intent).catch(() => { }); }
|
|
42
|
+
/** Task identity the scheduler owns, for lifecycle evidence. The claimed hop
|
|
43
|
+
* wins; before a claim an env-assigned task is already a scheduler row. A
|
|
44
|
+
* synthetic session key returns null and the report stays home: a frame the
|
|
45
|
+
* ledger cannot attribute raises an unknown-session alert on that side. */
|
|
46
|
+
function swarmReportTaskId() {
|
|
47
|
+
for (const candidate of [state.swarmTask?.taskId, state.swarmSession?.taskId]) {
|
|
48
|
+
if (candidate && isSwarmTaskId(candidate))
|
|
49
|
+
return candidate.trim();
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
/** A report that failed to build or reach the daemon is recorded locally. The
|
|
54
|
+
* entry names the kind and the error, so a gap in the scheduler's view of this
|
|
55
|
+
* session is traceable to a frame this side can show. */
|
|
56
|
+
function reportFault(kind, e) {
|
|
57
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
58
|
+
try {
|
|
59
|
+
state.swarmSession?.transition({}, `swarm_report_fault:${kind}:${message}`);
|
|
60
|
+
}
|
|
61
|
+
catch { /* ledger unavailable */ }
|
|
62
|
+
}
|
|
63
|
+
/** Send the frame for an already-persisted watermark. One attempt per point:
|
|
64
|
+
* the op_id names (task, seq), and the next beat carries a fresh watermark, so
|
|
65
|
+
* a dropped frame costs one step of liveness evidence. */
|
|
66
|
+
async function sendSwarmReportFrame(kind, seq) {
|
|
67
|
+
const session = state.swarmSession;
|
|
68
|
+
const ws = state.workspace;
|
|
69
|
+
const taskId = swarmReportTaskId();
|
|
70
|
+
if (!session || !ws || !taskId)
|
|
71
|
+
return false;
|
|
72
|
+
let frame;
|
|
73
|
+
try {
|
|
74
|
+
frame = renderSwarmReport(kind, taskId, session.generation, seq);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
reportFault(kind, e);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
await sendSwarmReport(ws.socketPath, frame, swarmOpId(`report_${kind}`, `${taskId}:${seq}`));
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
reportFault(kind, e);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Advance the durable watermark and carry it to the daemon. Persisting under
|
|
90
|
+
* the report's own kind names the lifecycle point in the local ledger, and
|
|
91
|
+
* persisting before sending gives every point its own strictly increasing seq
|
|
92
|
+
* within this generation — the ordering the reducer's watermark compares
|
|
93
|
+
* against. */
|
|
94
|
+
async function emitSwarmReport(kind) {
|
|
95
|
+
const session = state.swarmSession;
|
|
96
|
+
if (!session)
|
|
97
|
+
return false;
|
|
98
|
+
try {
|
|
99
|
+
return await sendSwarmReportFrame(kind, session.transition({}, `swarm_report_${kind}`).seq);
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
reportFault(kind, e);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function swarmHeartbeat() {
|
|
107
|
+
const session = state.swarmSession;
|
|
108
|
+
if (!session)
|
|
109
|
+
return;
|
|
110
|
+
let seq;
|
|
111
|
+
try {
|
|
112
|
+
seq = session.heartbeat().seq;
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
session.transition({ recovery: "idle_fault" }, `swarm_heartbeat_fault:${e instanceof Error ? e.message : String(e)}`);
|
|
116
|
+
session.stopHeartbeat();
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
awaitIntent(sendSwarmReportFrame("heartbeat", seq));
|
|
120
|
+
}
|
|
121
|
+
function swarmSessionTransition(patch, kind) { state.swarmSession?.transition(patch, kind); }
|
|
122
|
+
async function sendSwarmIntent(kind, payload, send, maxAttempts = 3, opId = swarmOpId(kind)) {
|
|
123
|
+
const session = state.swarmSession;
|
|
124
|
+
if (!session)
|
|
125
|
+
return send(opId);
|
|
126
|
+
session.recordIntent(kind, payload, opId);
|
|
127
|
+
const intent = await session.retry(opId, async () => {
|
|
128
|
+
const receipt = await send(opId);
|
|
129
|
+
if (receipt && typeof receipt === "object" && receipt.ok === false)
|
|
130
|
+
throw new Error(JSON.stringify(receipt.error ?? receipt));
|
|
131
|
+
return receipt;
|
|
132
|
+
}, maxAttempts);
|
|
133
|
+
if (intent.status !== "accepted") {
|
|
134
|
+
session.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, `${kind}_fault`);
|
|
135
|
+
throw new Error(`${kind} intent exhausted${intent.last_error ? `: ${intent.last_error}` : ""}`);
|
|
136
|
+
}
|
|
137
|
+
return intent.receipt;
|
|
138
|
+
}
|
|
22
139
|
function needsReply(inbound = state.currentInbound) { return !!inbound && !inbound.replied && !inbound.noReply && !!state.workspace; }
|
|
23
140
|
function scheduleReminder(pi, delayMs = 30_000) {
|
|
24
141
|
clearReminder();
|
|
@@ -50,7 +167,7 @@ function scheduleReconnect(pi) {
|
|
|
50
167
|
if (!state.watching)
|
|
51
168
|
return;
|
|
52
169
|
try {
|
|
53
|
-
await startWatch(pi);
|
|
170
|
+
await startWatch(pi, state.swarmContext);
|
|
54
171
|
}
|
|
55
172
|
catch {
|
|
56
173
|
scheduleReconnect(pi);
|
|
@@ -98,54 +215,45 @@ async function historyTaskDone(taskId) {
|
|
|
98
215
|
async function catchUpSwarmHistory(pi, ctx) {
|
|
99
216
|
if (swarmSlot.task().taskId)
|
|
100
217
|
return;
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
// Env task first: exact task_id match on inbound, even if an out exists
|
|
127
|
-
// (out may belong to a previous generation; scheduler owns the truth).
|
|
128
|
-
if (env) {
|
|
129
|
-
for (const text of inbounds) {
|
|
130
|
-
const p = parseSwarmHeader(text);
|
|
131
|
-
if (p.header.task_id === env) {
|
|
132
|
-
if (handleSwarmInbound(pi, text, undefined, ctx))
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
// Fallback: newest inbound with no out yet.
|
|
138
|
-
for (let i = inbounds.length - 1; i >= 0; i--) {
|
|
139
|
-
const p = parseSwarmHeader(inbounds[i]);
|
|
140
|
-
if (outs.has(p.header.task_id))
|
|
141
|
-
continue;
|
|
142
|
-
if (p.header.task_id === env)
|
|
143
|
-
continue; // already tried above
|
|
144
|
-
if (handleSwarmInbound(pi, inbounds[i]))
|
|
145
|
-
return;
|
|
218
|
+
const { request } = await import("./onlyne.js");
|
|
219
|
+
if (!state.workspace)
|
|
220
|
+
return;
|
|
221
|
+
const env = envTaskId();
|
|
222
|
+
const res = await request(state.workspace.socketPath, { id: "hist", op: "fetch_channel_history", channel_id: "loopback", limit: 30 });
|
|
223
|
+
const items = res?.data ?? res ?? [];
|
|
224
|
+
if (!Array.isArray(items))
|
|
225
|
+
return;
|
|
226
|
+
const inbounds = [];
|
|
227
|
+
const outs = new Set();
|
|
228
|
+
for (const m of items) {
|
|
229
|
+
const text = m?.text ?? m?.content ?? "";
|
|
230
|
+
if (typeof text !== "string" || !text.startsWith("---swarm"))
|
|
231
|
+
continue;
|
|
232
|
+
if (text.startsWith("---swarm-ctl"))
|
|
233
|
+
continue;
|
|
234
|
+
// Own lifecycle evidence shares this history. A report frame carries no
|
|
235
|
+
// task header, so it must pass before the malformed-header check.
|
|
236
|
+
if (isSwarmReportFrame(text))
|
|
237
|
+
continue;
|
|
238
|
+
const p = parseSwarmHeader(text);
|
|
239
|
+
if (!p) {
|
|
240
|
+
if (text.startsWith("---swarm"))
|
|
241
|
+
state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, "swarm_protocol_fault");
|
|
242
|
+
continue;
|
|
146
243
|
}
|
|
244
|
+
if (m?.direction === "outbound")
|
|
245
|
+
outs.add(p.header.task_id);
|
|
246
|
+
else
|
|
247
|
+
inbounds.push(text);
|
|
147
248
|
}
|
|
148
|
-
|
|
249
|
+
// A scheduler-created terminal has an env task id. In that case this
|
|
250
|
+
// selector returns only the matching wire or undefined: it must never
|
|
251
|
+
// fall back to an old history task while the new FIFO write races the
|
|
252
|
+
// initial catchup. Manual ready-pool sessions have no env and retain
|
|
253
|
+
// newest-unclosed fallback selection.
|
|
254
|
+
const selected = selectHistoryDelivery(env || undefined, inbounds, outs);
|
|
255
|
+
if (selected)
|
|
256
|
+
handleSwarmInbound(pi, selected, undefined, ctx);
|
|
149
257
|
}
|
|
150
258
|
/** Pin the tab title for a claimed hop. Pi rewrites the create-time title on
|
|
151
259
|
* boot and on idle transitions, and the scheduler-side rename races those
|
|
@@ -159,27 +267,58 @@ function pinSwarmTitle(ctx, taskId) {
|
|
|
159
267
|
}
|
|
160
268
|
/** Swarm-mode inbound path: claim one hop, inject via followUp, never wait. */
|
|
161
269
|
function handleSwarmInbound(pi, text, eventSeq, ctx) {
|
|
162
|
-
|
|
163
|
-
|
|
270
|
+
// A report frame is this session's own evidence relayed back on the event
|
|
271
|
+
// stream. It is neither a task delivery nor a protocol fault.
|
|
272
|
+
if (isSwarmReportFrame(text))
|
|
273
|
+
return false;
|
|
274
|
+
let parsed;
|
|
275
|
+
try {
|
|
276
|
+
parsed = parseSwarmHeader(text);
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, `swarm_protocol_fault:${e instanceof Error ? e.message : String(e)}`);
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
if (!parsed) {
|
|
283
|
+
if (text.startsWith("---swarm"))
|
|
284
|
+
state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, "swarm_protocol_fault");
|
|
164
285
|
return false;
|
|
286
|
+
}
|
|
165
287
|
if (state.socket && eventSeq !== undefined)
|
|
166
|
-
|
|
288
|
+
awaitIntent(consumeEvent(state.socket, eventSeq).catch(() => { }));
|
|
167
289
|
// Slot transitions live in SwarmSlot (unit-tested); here we only mirror
|
|
168
290
|
// the claimed task into session state. A claimed session never accepts
|
|
169
291
|
// another task; downstream work spawns new tasks via swarm_send.
|
|
170
|
-
const outcome =
|
|
292
|
+
const outcome = (() => { try {
|
|
293
|
+
return swarmSlot.handle(pi, text, envTaskId() || undefined, state.workspace?.root);
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, `swarm_slot_fault:${e instanceof Error ? e.message : String(e)}`);
|
|
297
|
+
return "fault";
|
|
298
|
+
} })();
|
|
171
299
|
if (outcome === "claimed" || outcome === "yielded") {
|
|
172
300
|
const cur = swarmSlot.task();
|
|
173
301
|
state.swarmTask = { taskId: cur.taskId, from: cur.from, transferSendTo: cur.transferSendTo, attempt: cur.attempt };
|
|
302
|
+
state.swarmTaskKey = cur.taskId;
|
|
303
|
+
const sessionCtx = state.swarmContext;
|
|
304
|
+
if (sessionCtx?.appendEntry) {
|
|
305
|
+
const restored = restoreSwarmState(sessionCtx, cur.taskId);
|
|
306
|
+
state.swarmSession?.stopHeartbeat();
|
|
307
|
+
state.swarmSession = new SwarmSessionState(sessionCtx, cur.taskId, restored);
|
|
308
|
+
state.swarmSession.startSession();
|
|
309
|
+
state.swarmSession.transition({ agent: "ready", resource: "attached", lifecycle: "working", recovery: "idle_waiting" }, "swarm_task_claimed");
|
|
310
|
+
state.swarmSession.startHeartbeat(10_000, swarmHeartbeat);
|
|
311
|
+
}
|
|
174
312
|
if (ctx)
|
|
175
313
|
void pinSwarmTitle(ctx, cur.taskId);
|
|
176
314
|
}
|
|
177
315
|
return outcome === "claimed" || outcome === "yielded";
|
|
178
316
|
}
|
|
179
|
-
async function startWatch(pi) {
|
|
317
|
+
async function startWatch(pi, ctx) {
|
|
180
318
|
state.workspace = findWorkspace(state.cwd);
|
|
181
319
|
if (!state.workspace)
|
|
182
320
|
throw new Error("current workspace has no .onlyne configuration");
|
|
321
|
+
state.swarmContext = ctx ?? state.swarmContext ?? cachedCtx;
|
|
183
322
|
refreshSwarmFlag();
|
|
184
323
|
if (state.reconnectTimer)
|
|
185
324
|
clearTimeout(state.reconnectTimer);
|
|
@@ -212,20 +351,41 @@ async function startWatch(pi) {
|
|
|
212
351
|
// the slot guard + scheduler task_id dedup.
|
|
213
352
|
if (parseSwarmCtl(inbound.text)) {
|
|
214
353
|
const ctl = parseSwarmCtl(inbound.text);
|
|
215
|
-
|
|
354
|
+
awaitIntent(handleSwarmRecycle(ctl.task_id, ctl.reason));
|
|
216
355
|
return;
|
|
217
356
|
}
|
|
218
|
-
void catchUpSwarmHistory(pi);
|
|
357
|
+
void Promise.resolve(catchUpSwarmHistory(pi)).catch((e) => state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, `history_catchup_fault:${e instanceof Error ? e.message : String(e)}`));
|
|
219
358
|
handleSwarmInbound(pi, inbound.text, line.event_seq);
|
|
220
359
|
}, () => { if (state.socket === socket)
|
|
221
360
|
scheduleReconnect(pi); }, { priority: 1 });
|
|
222
361
|
state.socket = socket;
|
|
223
362
|
state.watching = true;
|
|
224
|
-
const task = envTaskId();
|
|
225
363
|
try {
|
|
226
|
-
await
|
|
364
|
+
await awaitSubscriptionAck(socket);
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
state.swarmSession?.transition({ agent: "gone", resource: "closed", recovery: "idle_fault" }, "subscribe_events_fault");
|
|
368
|
+
throw e;
|
|
369
|
+
}
|
|
370
|
+
const task = envTaskId();
|
|
371
|
+
const sessionTaskId = task || state.swarmTaskKey || `session:${terminalHandle() || randomUUID()}`;
|
|
372
|
+
state.swarmTaskKey = sessionTaskId;
|
|
373
|
+
const sessionCtx = state.swarmContext;
|
|
374
|
+
if (sessionCtx?.appendEntry) {
|
|
375
|
+
const restored = restoreSwarmState(sessionCtx, sessionTaskId);
|
|
376
|
+
state.swarmSession?.stopHeartbeat();
|
|
377
|
+
state.swarmSession = new SwarmSessionState(sessionCtx, sessionTaskId, restored);
|
|
378
|
+
state.swarmSession.startSession();
|
|
379
|
+
}
|
|
380
|
+
const readyPayload = { workspace: ws.root, terminal_handle: terminalHandle() };
|
|
381
|
+
await sendSwarmIntent("swarm_ready", readyPayload, (opId) => swarmReady(ws.socketPath, ws.root, terminalHandle(), state.swarmSession?.snapshot(), opId), currentConfig().outbound.retry.attempts);
|
|
382
|
+
if (state.swarmSession) {
|
|
383
|
+
state.swarmSession.transition({ agent: "ready", resource: "attached", lifecycle: "idle", recovery: "idle_waiting" }, "swarm_ready");
|
|
384
|
+
// Awaits the daemon receipt: the scheduler holds this session's first
|
|
385
|
+
// watermark before it reads anything else from the workspace.
|
|
386
|
+
await emitSwarmReport("ready");
|
|
387
|
+
state.swarmSession.startHeartbeat(10_000, swarmHeartbeat);
|
|
227
388
|
}
|
|
228
|
-
catch { /* scheduler may read env fallback */ }
|
|
229
389
|
// The task may already sit in history (scheduler wrote in before this
|
|
230
390
|
// session finished starting). Claim it now instead of waiting for a
|
|
231
391
|
// live event that already fired.
|
|
@@ -252,8 +412,13 @@ async function startWatch(pi) {
|
|
|
252
412
|
state.watching = true;
|
|
253
413
|
return `watching ${state.workspace.root} (${state.owner})`;
|
|
254
414
|
}
|
|
255
|
-
function stopWatch() { if (state.reconnectTimer)
|
|
256
|
-
clearTimeout(state.reconnectTimer); state.reconnectTimer = undefined; clearReminder(); state.socket?.destroy(); state.socket = undefined; stopProcess(state.child); state.child = undefined;
|
|
415
|
+
function stopWatch(clearSession = true) { if (state.reconnectTimer)
|
|
416
|
+
clearTimeout(state.reconnectTimer); state.reconnectTimer = undefined; clearReminder(); state.socket?.destroy(); state.socket = undefined; stopProcess(state.child); state.child = undefined; if (clearSession) {
|
|
417
|
+
state.swarmSession?.shutdown();
|
|
418
|
+
state.swarmSession = undefined;
|
|
419
|
+
}
|
|
420
|
+
else
|
|
421
|
+
state.swarmSession?.stopHeartbeat(); state.watching = false; state.owner = "stopped"; state.swarmTask = undefined; swarmSlot.clear(); return "watch stopped"; }
|
|
257
422
|
async function startDaemon() { state.workspace = findWorkspace(state.cwd); if (!state.workspace)
|
|
258
423
|
throw new Error("current workspace has no .onlyne configuration"); refreshSwarmFlag(); const conn = await connectDaemon(state.workspace, true); state.owner = conn.owner; state.child = conn.process; return `daemon ${state.owner === "extension" ? "started" : "already running"} for ${state.workspace.root}`; }
|
|
259
424
|
async function stopDaemon() { if (!state.workspace)
|
|
@@ -272,7 +437,6 @@ function swarmTreePath() {
|
|
|
272
437
|
throw new Error("onlyne workspace not found");
|
|
273
438
|
return treePathForWorkspace(state.workspace.root);
|
|
274
439
|
}
|
|
275
|
-
/** swarm_complete: hand over. Writes the out message carrying this hop's header (done signal). */
|
|
276
440
|
async function swarmComplete(text) {
|
|
277
441
|
if (!state.workspace)
|
|
278
442
|
throw new Error("onlyne workspace not found");
|
|
@@ -281,63 +445,80 @@ async function swarmComplete(text) {
|
|
|
281
445
|
throw new Error("no active swarm task");
|
|
282
446
|
const { renderSwarmHeader } = await import("./swarm.js");
|
|
283
447
|
const wire = renderSwarmHeader({ task_id: task.taskId, from: swarmTreePath(), transfer_send_to: task.transferSendTo, attempt: task.attempt }, "", text);
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
448
|
+
const payload = { task_id: task.taskId, text: wire };
|
|
449
|
+
const result = await sendSwarmIntent("swarm_complete", payload, (opId) => sendWithRetry(state.workspace.socketPath, { channelId: "loopback" }, wire, 1, false, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_complete", task.taskId));
|
|
450
|
+
state.swarmSession?.transition({ agent: "ready", resource: "closing", lifecycle: "exited", recovery: "draining" }, "swarm_complete");
|
|
451
|
+
// Awaits the daemon receipt on the terminal evidence for this generation.
|
|
452
|
+
await emitSwarmReport("complete");
|
|
453
|
+
state.swarmTask = undefined;
|
|
454
|
+
swarmSlot.clear();
|
|
455
|
+
clearReminder();
|
|
456
|
+
try {
|
|
457
|
+
stopWatch(false);
|
|
458
|
+
}
|
|
459
|
+
catch { /* lifecycle cleanup */ }
|
|
460
|
+
setTimeout(() => process.exit(0), 300);
|
|
461
|
+
return { ...(result && typeof result === "object" ? result : { ok: true }), taskId: task.taskId };
|
|
462
|
+
}
|
|
296
463
|
async function swarmQuit(reason) {
|
|
297
464
|
const task = state.swarmTask;
|
|
298
465
|
const taskId = task?.taskId ?? "";
|
|
299
466
|
const why = `quit:${reason ?? ""}`;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
await swarmRecycled(state.workspace.socketPath, taskId, terminalHandle(), why);
|
|
467
|
+
if (state.workspace && taskId) {
|
|
468
|
+
try {
|
|
469
|
+
await sendSwarmIntent("swarm_recycled", { task_id: taskId, reason: why }, (opId) => swarmRecycled(state.workspace.socketPath, taskId, terminalHandle(), why, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_recycled", `quit:${taskId}`));
|
|
303
470
|
}
|
|
471
|
+
catch { /* fault persists; quit still exits */ }
|
|
304
472
|
}
|
|
305
|
-
|
|
473
|
+
// A quit writes no out, so Failed is the accurate projection of this hop.
|
|
474
|
+
// The report carries this session's own watermark next to the swarm_recycled
|
|
475
|
+
// ack above, and its receipt is collected before the exit path runs.
|
|
476
|
+
await emitSwarmReport("fault");
|
|
306
477
|
clearReminder();
|
|
307
478
|
try {
|
|
308
|
-
stopWatch();
|
|
479
|
+
stopWatch(false);
|
|
309
480
|
}
|
|
310
|
-
catch { /*
|
|
481
|
+
catch { /* lifecycle cleanup */ }
|
|
311
482
|
state.swarmTask = undefined;
|
|
312
483
|
swarmSlot.clear();
|
|
313
484
|
setTimeout(() => process.exit(0), 300);
|
|
314
485
|
return { quit: true, taskId, reason: reason ?? "" };
|
|
315
486
|
}
|
|
316
|
-
/** Handle a downlink recycle signal: accept, ack, release, self-terminate.
|
|
317
|
-
* No out is written (the scheduler already recorded the terminal state). */
|
|
318
487
|
async function handleSwarmRecycle(ctlTaskId, reason) {
|
|
319
488
|
const claimed = swarmSlot.task().taskId;
|
|
320
489
|
const taskId = ctlTaskId === "*" ? (claimed ?? "") : ctlTaskId;
|
|
321
490
|
if (claimed && ctlTaskId !== "*" && claimed !== ctlTaskId)
|
|
322
491
|
return false;
|
|
323
492
|
const why = `recycle:${reason}`;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
493
|
+
// The scheduler initiated this recycle, so its own ledger already holds the
|
|
494
|
+
// truth about the hop. A report from here would restate a transition that
|
|
495
|
+
// side drove, hence no lifecycle report on this path.
|
|
496
|
+
if (state.workspace && taskId) {
|
|
497
|
+
try {
|
|
498
|
+
await sendSwarmIntent("swarm_recycled", { task_id: taskId, reason: why }, (opId) => swarmRecycled(state.workspace.socketPath, taskId, terminalHandle(), why, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_recycled", `recycle:${taskId}`));
|
|
327
499
|
}
|
|
500
|
+
catch { /* fault persists; recycle still exits */ }
|
|
328
501
|
}
|
|
329
|
-
catch { /* daemon may be gone; exit anyway */ }
|
|
330
502
|
clearReminder();
|
|
331
503
|
try {
|
|
332
|
-
stopWatch();
|
|
504
|
+
stopWatch(false);
|
|
333
505
|
}
|
|
334
|
-
catch { /*
|
|
506
|
+
catch { /* lifecycle cleanup */ }
|
|
335
507
|
state.swarmTask = undefined;
|
|
336
508
|
swarmSlot.clear();
|
|
337
509
|
setTimeout(() => process.exit(0), 300);
|
|
338
510
|
return true;
|
|
339
511
|
}
|
|
340
|
-
/**
|
|
512
|
+
/** Resolve a scheduler target symlink to that workspace's daemon socket. */
|
|
513
|
+
function swarmTargetSocket(linkPath, workspaceRoot) {
|
|
514
|
+
const targetIn = realpathSync(linkPath);
|
|
515
|
+
const root = resolve(workspaceRoot);
|
|
516
|
+
if (!targetIn.startsWith(root + "/") || !targetIn.endsWith("/.onlyne/channels/loopback/in"))
|
|
517
|
+
throw new Error(`invalid swarm_send target: ${linkPath}`);
|
|
518
|
+
const targetOnlyne = dirname(dirname(dirname(targetIn)));
|
|
519
|
+
return join(targetOnlyne, "run", "s");
|
|
520
|
+
}
|
|
521
|
+
/** swarm_send: spawn downstream through the target daemon's loopback RPC. */
|
|
341
522
|
async function swarmSend(to, text) {
|
|
342
523
|
if (!state.workspace)
|
|
343
524
|
throw new Error("onlyne workspace not found");
|
|
@@ -345,27 +526,13 @@ async function swarmSend(to, text) {
|
|
|
345
526
|
if (!task)
|
|
346
527
|
throw new Error("no active swarm task");
|
|
347
528
|
const { renderSwarmHeader } = await import("./swarm.js");
|
|
348
|
-
// Resolve the target workspace dir: root "." or a tree path under the swarm tree.
|
|
349
|
-
// The scheduler owns the tree; here we only verify the send-side symlink exists
|
|
350
|
-
// (missing target = dangling link = error, never a blind FIFO write).
|
|
351
529
|
const wsRoot = state.workspace.root;
|
|
352
|
-
const linkPath = to === "." || to === "_root"
|
|
353
|
-
? join(wsRoot, "onlyne_in", "_root")
|
|
354
|
-
: join(wsRoot, "onlyne_in", ...to.split("/").filter(Boolean));
|
|
355
|
-
const resolved = resolve(wsRoot);
|
|
356
|
-
const linkDir = resolve(linkPath);
|
|
357
|
-
if (!linkDir.startsWith(resolved))
|
|
358
|
-
throw new Error(`invalid swarm_send target: ${to}`);
|
|
359
|
-
if (!existsSync(linkPath))
|
|
360
|
-
throw new Error(`unknown swarm_send target (missing onlyne_in link): ${to}`);
|
|
530
|
+
const linkPath = to === "." || to === "_root" ? join(wsRoot, "onlyne_in", "_root") : join(wsRoot, "onlyne_in", ...to.split("/").filter(Boolean));
|
|
361
531
|
const childId = randomUUID();
|
|
362
532
|
const wire = renderSwarmHeader({ task_id: childId, from: swarmTreePath(), transfer_send_to: task.taskId, attempt: 1 }, "", text);
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
catch (e) {
|
|
367
|
-
throw new Error(`swarm_send failed for ${to}: ${e instanceof Error ? e.message : String(e)}`);
|
|
368
|
-
}
|
|
533
|
+
const targetSocket = swarmTargetSocket(linkPath, wsRoot);
|
|
534
|
+
const payload = { child_id: childId, to, task_id: task.taskId, text: wire };
|
|
535
|
+
await sendSwarmIntent("swarm_send", payload, (opId) => loopback(targetSocket, wire, true, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_send", childId));
|
|
369
536
|
swarmSlot.noteSpawned(childId);
|
|
370
537
|
return { childId, to };
|
|
371
538
|
}
|
|
@@ -420,15 +587,16 @@ export default function onlyne(pi) {
|
|
|
420
587
|
process.once(sig, () => stopWatch());
|
|
421
588
|
pi.on("message_end", async (event) => { const text = typeof event.content === "string" ? event.content.trim() : ""; if (text && !text.startsWith("{") && !text.startsWith("[onlyne-internal]"))
|
|
422
589
|
state.lastValidOutput = text; });
|
|
423
|
-
pi.on("agent_start", async () => clearReminder());
|
|
590
|
+
pi.on("agent_start", async () => { clearReminder(); swarmSessionTransition({ agent: "running", lifecycle: "working", recovery: "idle_waiting" }, "turn_start"); awaitIntent(emitSwarmReport("turn_started")); reportHopBusy().catch(() => { }); });
|
|
424
591
|
pi.on("agent_end", async () => { if (state.swarm) {
|
|
425
592
|
const cur = swarmSlot.task();
|
|
426
593
|
if (cur.taskId && cachedCtx)
|
|
427
594
|
void pinSwarmTitle(cachedCtx, cur.taskId);
|
|
595
|
+
swarmSessionTransition({ agent: "idle", lifecycle: "idle", recovery: "idle_waiting" }, "turn_idle");
|
|
428
596
|
scheduleSwarmExitReminder(pi);
|
|
429
597
|
}
|
|
430
598
|
else
|
|
431
|
-
scheduleReminder(pi); });
|
|
599
|
+
scheduleReminder(pi); reportHopIdle().catch(() => { }); });
|
|
432
600
|
pi.registerCommand("onlyne", {
|
|
433
601
|
description: "Onlyne watch/status/config commands",
|
|
434
602
|
getArgumentCompletions: (prefix) => {
|
|
@@ -496,9 +664,9 @@ function applyToolSurface(pi) {
|
|
|
496
664
|
}
|
|
497
665
|
function registerNormalTools(pi) {
|
|
498
666
|
pi.registerTool(defineTool({ name: "onlyne_daemon_start", label: "Onlyne daemon start", description: "Start or connect to the current workspace-local Onlyne daemon managed by pi-onlyne.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const res = await startDaemon(); return textResult(res, { owner: state.owner, workspace: state.workspace?.root }); } }));
|
|
499
|
-
pi.registerTool(defineTool({ name: "onlyne_daemon_stop", label: "Onlyne daemon stop", description: "Stop the current workspace-local Onlyne daemon
|
|
667
|
+
pi.registerTool(defineTool({ name: "onlyne_daemon_stop", label: "Onlyne daemon stop", description: "Stop the current workspace-local Onlyne daemon through pi-onlyne lifecycle management. The tool also sends shutdown to a daemon already running in this workspace.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const res = await stopDaemon(); return textResult(res, { owner: state.owner, workspace: state.workspace?.root }); } }));
|
|
500
668
|
pi.registerTool(defineTool({ name: "onlyne_daemon_restart", label: "Onlyne daemon restart", description: "Restart the current workspace-local Onlyne daemon through pi-onlyne lifecycle management.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const res = await restartDaemon(); return textResult(res, { owner: state.owner, workspace: state.workspace?.root }); } }));
|
|
501
|
-
pi.registerTool(defineTool({ name: "onlyne_reply", label: "Onlyne reply", description: "Reply with
|
|
669
|
+
pi.registerTool(defineTool({ name: "onlyne_reply", label: "Onlyne reply", description: "Reply with Markdown to the current Onlyne inbound message. Use Markdown by default and preserve the message as one document.", parameters: Type.Object({ text: Type.String() }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await reply(params.text))); } }));
|
|
502
670
|
pi.registerTool(defineTool({ name: "onlyne_send", label: "Onlyne send", description: "Send Markdown to the channel's configured Onlyne conversation. Set rawText=true only for literal plain text.", parameters: Type.Object({ channelId: Type.String(), text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
|
|
503
671
|
throw new Error("onlyne workspace not found"); const res = await sendWithRetry(state.workspace.socketPath, params, params.text, currentConfig().outbound.retry.attempts, params.rawText ?? false); return textResult(JSON.stringify(res), res); } }));
|
|
504
672
|
pi.registerTool(defineTool({ name: "onlyne_broadcast", label: "Onlyne broadcast", description: "Send Markdown to many configured Onlyne channels concurrently. Set rawText=true only for literal plain text.", parameters: Type.Object({ targets: Type.Array(Type.Object({ channelId: Type.String() })), text: Type.String(), rawText: Type.Optional(Type.Boolean()) }), executionMode: "parallel", async execute(_id, params) { if (!state.workspace)
|
|
@@ -513,9 +681,27 @@ function registerNormalTools(pi) {
|
|
|
513
681
|
function registerSwarmTools(pi) {
|
|
514
682
|
pi.registerTool(defineTool({ name: "swarm_complete", label: "Swarm complete", description: "Hand over this swarm hop: write the out message carrying this task header (done signal). The session becomes recyclable. Use once per swarm task.", parameters: Type.Object({ text: Type.String() }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await swarmComplete(params.text))); } }));
|
|
515
683
|
pi.registerTool(defineTool({ name: "swarm_quit", label: "Swarm quit", description: "Quit this swarm hop silently: no out is written and the scheduler records failed. Use when the task premise does not hold or there is nothing to do.", parameters: Type.Object({ reason: Type.Optional(Type.String()) }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await swarmQuit(params.reason))); } }));
|
|
516
|
-
pi.registerTool(defineTool({ name: "swarm_send", label: "Swarm send", description: "Spawn a downstream swarm task
|
|
684
|
+
pi.registerTool(defineTool({ name: "swarm_send", label: "Swarm send", description: "Spawn a downstream swarm task through the target workspace daemon loopback RPC with transfer_send_to set to the current task. Stable op_id makes retries idempotent; returns the child id without waiting. Errors on missing targets.", parameters: Type.Object({ to: Type.String(), text: Type.String() }), executionMode: "parallel", async execute(_id, params) { return textResult(JSON.stringify(await swarmSend(params.to, params.text))); } }));
|
|
517
685
|
pi.registerTool(defineTool({ name: "swarm_status", label: "Swarm status", description: "Read-only swarm state: current task id, from, workspace path, and spawned child ids.", parameters: Type.Object({}), executionMode: "parallel", async execute() { const cur = swarmSlot.task(); return textResult(JSON.stringify({ taskId: cur.taskId ?? null, from: cur.from, transferSendTo: cur.transferSendTo, workspace: state.workspace?.root ?? null, spawned: cur.sentChildIds })); } }));
|
|
518
686
|
}
|
|
687
|
+
/** R4 hop activity reporters: turn-granularity busy/idle over the existing
|
|
688
|
+
* swarm event channel. Fire-and-forget (failures silent): the scheduler
|
|
689
|
+
* treats missing rows as race residue, and its TTLs are the backstop.
|
|
690
|
+
* busy fires only with a live claimed task; idle fires only when the turn
|
|
691
|
+
* ended without an out (swarmTask still set). The decision (guard +
|
|
692
|
+
* pending_exit) is the shared, unit-tested hopActivityBody. */
|
|
693
|
+
async function reportHopBusy() {
|
|
694
|
+
const body = hopActivityBody("busy", { swarm: state.swarm, workspaceRoot: state.workspace?.root, terminal: terminalHandle(), taskId: state.swarmTask?.taskId });
|
|
695
|
+
if (!body || !state.workspace)
|
|
696
|
+
return;
|
|
697
|
+
await sendSwarmIntent("swarm_busy", body, (opId) => swarmBusy(state.workspace.socketPath, body, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_busy", `${state.swarmTask?.taskId ?? ""}:${state.swarmSession?.seq ?? 0}`));
|
|
698
|
+
}
|
|
699
|
+
async function reportHopIdle() {
|
|
700
|
+
const body = hopActivityBody("idle", { swarm: state.swarm, workspaceRoot: state.workspace?.root, terminal: terminalHandle(), taskId: state.swarmTask?.taskId });
|
|
701
|
+
if (!body || !state.workspace)
|
|
702
|
+
return;
|
|
703
|
+
await sendSwarmIntent("swarm_idle", body, (opId) => swarmIdle(state.workspace.socketPath, body, opId), currentConfig().outbound.retry.attempts, swarmOpId("swarm_idle", `${state.swarmTask?.taskId ?? ""}:${state.swarmSession?.seq ?? 0}`));
|
|
704
|
+
}
|
|
519
705
|
/** Swarm exit guard: at agent end, an unfinished hop gets one reminder, then auto-quit. */
|
|
520
706
|
function scheduleSwarmExitReminder(pi, delayMs = 30_000) {
|
|
521
707
|
clearReminder();
|
|
@@ -574,3 +760,21 @@ async function setSwarm(pi, enabled) {
|
|
|
574
760
|
}
|
|
575
761
|
return `swarm ${enabled ? "on" : "off"} (watch not running)`;
|
|
576
762
|
}
|
|
763
|
+
/** Test seam: drive the lifecycle-report wiring against a stub daemon socket.
|
|
764
|
+
* No live pi session and no real daemon are involved; the seam exposes the
|
|
765
|
+
* same closures the hooks call. Mirrors `__swarmSlotForTest`. */
|
|
766
|
+
export function __swarmReportForTest() {
|
|
767
|
+
return {
|
|
768
|
+
setWorkspace: (ws) => { state.workspace = ws; },
|
|
769
|
+
beginSession: (appendEntry, taskId) => {
|
|
770
|
+
state.swarmSession?.shutdown();
|
|
771
|
+
state.swarmSession = new SwarmSessionState({ appendEntry }, taskId);
|
|
772
|
+
state.swarmSession.startSession();
|
|
773
|
+
return { generation: state.swarmSession.generation, seq: state.swarmSession.seq };
|
|
774
|
+
},
|
|
775
|
+
setTask: (task) => { state.swarmTask = task; },
|
|
776
|
+
reportTaskId: swarmReportTaskId,
|
|
777
|
+
emit: emitSwarmReport,
|
|
778
|
+
beat: swarmHeartbeat,
|
|
779
|
+
};
|
|
780
|
+
}
|