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/onlyne.d.ts
CHANGED
|
@@ -4,12 +4,15 @@ import type { Workspace } from "./workspace.js";
|
|
|
4
4
|
export interface OnlyneRequest {
|
|
5
5
|
id: string;
|
|
6
6
|
op: string;
|
|
7
|
+
op_id?: string;
|
|
7
8
|
channel_id?: string;
|
|
9
|
+
conversation_id?: string;
|
|
8
10
|
message_id?: string;
|
|
9
11
|
text?: string;
|
|
10
12
|
format?: "plain" | "markdown";
|
|
11
13
|
raw_text?: boolean;
|
|
12
14
|
limit?: number;
|
|
15
|
+
offset?: number;
|
|
13
16
|
priority?: number;
|
|
14
17
|
consume_timeout_ms?: number;
|
|
15
18
|
event_seq?: number;
|
|
@@ -22,10 +25,13 @@ export interface SendResult extends SendTarget {
|
|
|
22
25
|
error?: string;
|
|
23
26
|
}
|
|
24
27
|
export declare function request(socketPath: string, req: OnlyneRequest): Promise<any>;
|
|
28
|
+
export interface SwarmSubscription extends Socket {
|
|
29
|
+
ack: Promise<any>;
|
|
30
|
+
}
|
|
25
31
|
export declare function subscribe(socketPath: string, onLine: (line: any) => void, onDisconnect?: () => void, opts?: {
|
|
26
32
|
priority?: number;
|
|
27
33
|
consumeTimeoutMs?: number;
|
|
28
|
-
}):
|
|
34
|
+
}): SwarmSubscription;
|
|
29
35
|
export declare function waitForSocket(socketPath: string, timeoutMs?: number): Promise<void>;
|
|
30
36
|
export declare function connectDaemon(ws: Workspace, startIfMissing?: boolean): Promise<{
|
|
31
37
|
owner: "external" | "extension";
|
|
@@ -33,10 +39,32 @@ export declare function connectDaemon(ws: Workspace, startIfMissing?: boolean):
|
|
|
33
39
|
}>;
|
|
34
40
|
export declare function shutdownDaemon(ws: Workspace, child?: ChildProcess): Promise<void>;
|
|
35
41
|
export declare function stopProcess(child?: ChildProcess): void;
|
|
36
|
-
export declare function swarmReady(socketPath: string, workspace: string, terminalHandle: string): Promise<any>;
|
|
37
|
-
export declare function swarmRecycled(socketPath: string, taskId: string, terminalHandle: string, reason: string): Promise<any>;
|
|
42
|
+
export declare function swarmReady(socketPath: string, workspace: string, terminalHandle: string, snapshot?: unknown, opId?: string): Promise<any>;
|
|
43
|
+
export declare function swarmRecycled(socketPath: string, taskId: string, terminalHandle: string, reason: string, opId?: string): Promise<any>;
|
|
44
|
+
/** R4 hop activity: turn started. Fire-and-forget; the scheduler treats a
|
|
45
|
+
* missing task row as race residue and drops it silently. `body` comes from
|
|
46
|
+
* swarm.ts hopActivityBody (the shared, unit-tested decision). */
|
|
47
|
+
export declare function swarmBusy(socketPath: string, body: {
|
|
48
|
+
workspace: string;
|
|
49
|
+
terminal_handle: string;
|
|
50
|
+
task_id: string;
|
|
51
|
+
}, opId?: string): Promise<any>;
|
|
52
|
+
/** R4 hop activity: turn ended with no out. `body.pending_exit` mirrors whether
|
|
53
|
+
* the hop still awaits swarm_complete — the scheduler only records it. */
|
|
54
|
+
export declare function swarmIdle(socketPath: string, body: {
|
|
55
|
+
workspace: string;
|
|
56
|
+
terminal_handle: string;
|
|
57
|
+
task_id: string;
|
|
58
|
+
pending_exit?: boolean;
|
|
59
|
+
}, opId?: string): Promise<any>;
|
|
38
60
|
export declare function consumeEvent(socket: Socket, eventSeq: number): Promise<void>;
|
|
39
|
-
export declare function loopback(socketPath: string, text: string, rawText?: boolean): Promise<any>;
|
|
61
|
+
export declare function loopback(socketPath: string, text: string, rawText?: boolean, opId?: string): Promise<any>;
|
|
62
|
+
/** Versioned lifecycle evidence for the scheduler reducer. The frame travels as
|
|
63
|
+
* a raw-text loopback inbound: the daemon stores `text` verbatim and publishes
|
|
64
|
+
* it on the same event stream the report consumer reads. `raw_text` keeps the
|
|
65
|
+
* frame out of Markdown processing, and `opId` makes a socket-level retry
|
|
66
|
+
* return the stored receipt. */
|
|
67
|
+
export declare function sendSwarmReport(socketPath: string, frame: string, opId?: string): Promise<any>;
|
|
40
68
|
export declare function markConsumed(socketPath: string, messageId: string): Promise<any>;
|
|
41
|
-
export declare function sendWithRetry(socketPath: string, target: SendTarget, text: string, attempts: number, rawText?: boolean): Promise<SendResult>;
|
|
69
|
+
export declare function sendWithRetry(socketPath: string, target: SendTarget, text: string, attempts: number, rawText?: boolean, opId?: string): Promise<SendResult>;
|
|
42
70
|
export declare function broadcast(socketPath: string, targets: SendTarget[], text: string, attempts: number, concurrency: number, rawText?: boolean): Promise<SendResult[]>;
|
package/dist/onlyne.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createConnection } from "node:net";
|
|
3
|
+
import { SWARM_REPORT_PREFIX } from "./swarm.js";
|
|
3
4
|
export function request(socketPath, req) {
|
|
4
5
|
return new Promise((resolve, reject) => {
|
|
5
6
|
const socket = createConnection(socketPath);
|
|
@@ -10,7 +11,11 @@ export function request(socketPath, req) {
|
|
|
10
11
|
socket.on("data", (chunk) => { data += chunk; const idx = data.indexOf("\n"); if (idx >= 0) {
|
|
11
12
|
socket.end();
|
|
12
13
|
try {
|
|
13
|
-
|
|
14
|
+
const response = JSON.parse(data.slice(0, idx));
|
|
15
|
+
if (response && response.ok === false)
|
|
16
|
+
reject(new Error(response.error?.message ?? JSON.stringify(response.error ?? response)));
|
|
17
|
+
else
|
|
18
|
+
resolve(response);
|
|
14
19
|
}
|
|
15
20
|
catch (e) {
|
|
16
21
|
reject(e);
|
|
@@ -23,8 +28,11 @@ export function subscribe(socketPath, onLine, onDisconnect, opts) {
|
|
|
23
28
|
let buf = "";
|
|
24
29
|
let closed = false;
|
|
25
30
|
socket.setEncoding("utf8");
|
|
31
|
+
let resolveAck;
|
|
32
|
+
let rejectAck;
|
|
33
|
+
socket.ack = new Promise((resolve, reject) => { resolveAck = resolve; rejectAck = reject; });
|
|
26
34
|
const disconnect = () => { if (closed)
|
|
27
|
-
return; closed = true; onDisconnect?.(); };
|
|
35
|
+
return; closed = true; rejectAck(new Error("onlyne subscription disconnected")); onDisconnect?.(); };
|
|
28
36
|
socket.on("error", disconnect);
|
|
29
37
|
socket.on("close", disconnect);
|
|
30
38
|
socket.on("connect", () => { if (!socket.destroyed)
|
|
@@ -38,9 +46,16 @@ export function subscribe(socketPath, onLine, onDisconnect, opts) {
|
|
|
38
46
|
if (!raw.trim())
|
|
39
47
|
continue;
|
|
40
48
|
try {
|
|
41
|
-
|
|
49
|
+
const line = JSON.parse(raw);
|
|
50
|
+
if (line.id === "sub") {
|
|
51
|
+
if (line.ok)
|
|
52
|
+
resolveAck(line);
|
|
53
|
+
else
|
|
54
|
+
rejectAck(new Error(line.error?.message ?? "subscribe_events rejected"));
|
|
55
|
+
}
|
|
56
|
+
onLine(line);
|
|
42
57
|
}
|
|
43
|
-
catch { /*
|
|
58
|
+
catch { /* malformed event lines stay outside the lifecycle protocol */ }
|
|
44
59
|
} });
|
|
45
60
|
return socket;
|
|
46
61
|
}
|
|
@@ -109,11 +124,22 @@ export function stopProcess(child) { if (!child || child.killed)
|
|
|
109
124
|
child.kill("SIGTERM");
|
|
110
125
|
}
|
|
111
126
|
catch { /* ignore */ } }
|
|
112
|
-
export async function swarmReady(socketPath, workspace, terminalHandle) {
|
|
113
|
-
return request(socketPath, { id: `swarm-ready-${Date.now()}`, op: "swarm_ready", text: JSON.stringify({ workspace, terminal_handle: terminalHandle }) });
|
|
127
|
+
export async function swarmReady(socketPath, workspace, terminalHandle, snapshot, opId) {
|
|
128
|
+
return request(socketPath, { id: `swarm-ready-${Date.now()}`, op: "swarm_ready", op_id: opId, text: JSON.stringify({ protocol: 2, workspace, terminal_handle: terminalHandle, snapshot }) });
|
|
129
|
+
}
|
|
130
|
+
export async function swarmRecycled(socketPath, taskId, terminalHandle, reason, opId) {
|
|
131
|
+
return request(socketPath, { id: `swarm-recycled-${Date.now()}`, op: "swarm_recycled", op_id: opId, text: JSON.stringify({ task_id: taskId, terminal_handle: terminalHandle, reason }) });
|
|
114
132
|
}
|
|
115
|
-
|
|
116
|
-
|
|
133
|
+
/** R4 hop activity: turn started. Fire-and-forget; the scheduler treats a
|
|
134
|
+
* missing task row as race residue and drops it silently. `body` comes from
|
|
135
|
+
* swarm.ts hopActivityBody (the shared, unit-tested decision). */
|
|
136
|
+
export async function swarmBusy(socketPath, body, opId) {
|
|
137
|
+
return request(socketPath, { id: `swarm-busy-${Date.now()}`, op: "swarm_busy", op_id: opId, text: JSON.stringify(body) });
|
|
138
|
+
}
|
|
139
|
+
/** R4 hop activity: turn ended with no out. `body.pending_exit` mirrors whether
|
|
140
|
+
* the hop still awaits swarm_complete — the scheduler only records it. */
|
|
141
|
+
export async function swarmIdle(socketPath, body, opId) {
|
|
142
|
+
return request(socketPath, { id: `swarm-idle-${Date.now()}`, op: "swarm_idle", op_id: opId, text: JSON.stringify(body) });
|
|
117
143
|
}
|
|
118
144
|
export async function consumeEvent(socket, eventSeq) {
|
|
119
145
|
return new Promise((resolve) => { try {
|
|
@@ -123,17 +149,27 @@ export async function consumeEvent(socket, eventSeq) {
|
|
|
123
149
|
resolve();
|
|
124
150
|
} });
|
|
125
151
|
}
|
|
126
|
-
export async function loopback(socketPath, text, rawText = true) {
|
|
127
|
-
return request(socketPath, { id: `loopback-${Date.now()}`, op: "loopback", text, raw_text: rawText });
|
|
152
|
+
export async function loopback(socketPath, text, rawText = true, opId) {
|
|
153
|
+
return request(socketPath, { id: `loopback-${Date.now()}`, op: "loopback", op_id: opId, text, raw_text: rawText });
|
|
154
|
+
}
|
|
155
|
+
/** Versioned lifecycle evidence for the scheduler reducer. The frame travels as
|
|
156
|
+
* a raw-text loopback inbound: the daemon stores `text` verbatim and publishes
|
|
157
|
+
* it on the same event stream the report consumer reads. `raw_text` keeps the
|
|
158
|
+
* frame out of Markdown processing, and `opId` makes a socket-level retry
|
|
159
|
+
* return the stored receipt. */
|
|
160
|
+
export async function sendSwarmReport(socketPath, frame, opId) {
|
|
161
|
+
if (!frame.startsWith(SWARM_REPORT_PREFIX))
|
|
162
|
+
throw new Error("swarm report frame needs the ---swarm-report header");
|
|
163
|
+
return loopback(socketPath, frame, true, opId);
|
|
128
164
|
}
|
|
129
165
|
export async function markConsumed(socketPath, messageId) {
|
|
130
166
|
return request(socketPath, { id: `consume-${Date.now()}`, op: "mark_io_consumed", message_id: messageId });
|
|
131
167
|
}
|
|
132
|
-
export async function sendWithRetry(socketPath, target, text, attempts, rawText = false) {
|
|
168
|
+
export async function sendWithRetry(socketPath, target, text, attempts, rawText = false, opId) {
|
|
133
169
|
let error = "unknown error";
|
|
134
170
|
for (let i = 0; i < Math.max(1, attempts); i++) {
|
|
135
171
|
try {
|
|
136
|
-
const res = await request(socketPath, { id: `send-${Date.now()}-${i}`, op: "send_message", channel_id: target.channelId, text, raw_text: rawText });
|
|
172
|
+
const res = await request(socketPath, { id: `send-${Date.now()}-${i}`, op: "send_message", op_id: opId, channel_id: target.channelId, text, raw_text: rawText });
|
|
137
173
|
if (res.ok)
|
|
138
174
|
return { ...target, ok: true };
|
|
139
175
|
error = res.error?.message ?? JSON.stringify(res.error ?? res);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export declare const SWARM_STATE_ENTRY = "pi-onlyne:swarm-state";
|
|
2
|
+
export declare const SWARM_PROTOCOL = 2;
|
|
3
|
+
export type AgentState = "booting" | "ready" | "running" | "idle" | "gone";
|
|
4
|
+
export type DeliveryState = "none" | "pending" | "retrying" | "accepted" | "exhausted";
|
|
5
|
+
export type ResourceState = "detached" | "attached" | "closing" | "closed";
|
|
6
|
+
export type Lifecycle = "created" | "working" | "idle" | "exited";
|
|
7
|
+
export type RecoveryState = "idle_waiting" | "idle_fault" | "draining";
|
|
8
|
+
export interface SwarmSnapshot {
|
|
9
|
+
protocol: 2;
|
|
10
|
+
task_id: string;
|
|
11
|
+
generation: number;
|
|
12
|
+
seq: number;
|
|
13
|
+
agent: AgentState;
|
|
14
|
+
delivery: DeliveryState;
|
|
15
|
+
resource: ResourceState;
|
|
16
|
+
lifecycle: Lifecycle;
|
|
17
|
+
recovery: RecoveryState;
|
|
18
|
+
pending_intents: string[];
|
|
19
|
+
at: string;
|
|
20
|
+
}
|
|
21
|
+
export interface SwarmIntent {
|
|
22
|
+
op_id: string;
|
|
23
|
+
kind: string;
|
|
24
|
+
payload: unknown;
|
|
25
|
+
attempts: number;
|
|
26
|
+
status: "pending" | "retrying" | "accepted" | "exhausted";
|
|
27
|
+
receipt?: unknown;
|
|
28
|
+
last_error?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface SwarmStateData {
|
|
31
|
+
protocol: 2;
|
|
32
|
+
task_id: string;
|
|
33
|
+
generation: number;
|
|
34
|
+
seq: number;
|
|
35
|
+
snapshot: SwarmSnapshot;
|
|
36
|
+
intents: SwarmIntent[];
|
|
37
|
+
}
|
|
38
|
+
export interface SessionEntry {
|
|
39
|
+
type?: string;
|
|
40
|
+
customType?: string;
|
|
41
|
+
data?: unknown;
|
|
42
|
+
}
|
|
43
|
+
export interface StateContext {
|
|
44
|
+
appendEntry: (customType: string, data?: unknown) => void;
|
|
45
|
+
sessionManager?: {
|
|
46
|
+
getEntries(): readonly SessionEntry[];
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export declare function restoreSwarmState(ctx: StateContext, taskId: string): SwarmStateData | undefined;
|
|
50
|
+
export declare class SwarmSessionState {
|
|
51
|
+
readonly taskId: string;
|
|
52
|
+
private append;
|
|
53
|
+
private _generation;
|
|
54
|
+
private _seq;
|
|
55
|
+
private _snapshot;
|
|
56
|
+
private _intents;
|
|
57
|
+
private timers;
|
|
58
|
+
private heartbeatTimer?;
|
|
59
|
+
constructor(ctx: StateContext, taskId: string, restored?: SwarmStateData);
|
|
60
|
+
startSession(): SwarmSnapshot;
|
|
61
|
+
get generation(): number;
|
|
62
|
+
get seq(): number;
|
|
63
|
+
get intents(): SwarmIntent[];
|
|
64
|
+
snapshot(): SwarmSnapshot;
|
|
65
|
+
private makeSnapshot;
|
|
66
|
+
private persist;
|
|
67
|
+
transition(patch: Partial<SwarmSnapshot>, kind?: string): SwarmSnapshot;
|
|
68
|
+
heartbeat(): SwarmSnapshot;
|
|
69
|
+
recordIntent(kind: string, payload: unknown, opId: string): SwarmIntent;
|
|
70
|
+
receipt(opId: string, receipt: unknown): boolean;
|
|
71
|
+
retry(opId: string, send: (intent: SwarmIntent) => Promise<unknown>, maxAttempts?: number): Promise<SwarmIntent>;
|
|
72
|
+
startHeartbeat(intervalMs?: number, run?: () => void): void;
|
|
73
|
+
stopHeartbeat(): void;
|
|
74
|
+
shutdown(): void;
|
|
75
|
+
}
|
|
76
|
+
export declare function compareSwarmVersion(a: {
|
|
77
|
+
generation: number;
|
|
78
|
+
seq: number;
|
|
79
|
+
}, b: {
|
|
80
|
+
generation: number;
|
|
81
|
+
seq: number;
|
|
82
|
+
}): "stale" | "duplicate" | "new";
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export const SWARM_STATE_ENTRY = "pi-onlyne:swarm-state";
|
|
2
|
+
export const SWARM_PROTOCOL = 2;
|
|
3
|
+
function isObject(v) { return !!v && typeof v === "object" && !Array.isArray(v); }
|
|
4
|
+
function validState(v) {
|
|
5
|
+
if (!isObject(v) || v.protocol !== 2 || typeof v.task_id !== "string" || typeof v.generation !== "number" || typeof v.seq !== "number" || !isObject(v.snapshot) || !Array.isArray(v.intents))
|
|
6
|
+
return false;
|
|
7
|
+
return v.snapshot.protocol === 2 && v.snapshot.task_id === v.task_id && v.snapshot.generation === v.generation && typeof v.snapshot.seq === "number";
|
|
8
|
+
}
|
|
9
|
+
export function restoreSwarmState(ctx, taskId) {
|
|
10
|
+
let found;
|
|
11
|
+
for (const entry of ctx.sessionManager?.getEntries() ?? []) {
|
|
12
|
+
if (entry.type !== "custom" || entry.customType !== SWARM_STATE_ENTRY)
|
|
13
|
+
continue;
|
|
14
|
+
if (!validState(entry.data))
|
|
15
|
+
throw new Error("malformed swarm v2 state entry");
|
|
16
|
+
if (entry.data.task_id !== taskId)
|
|
17
|
+
continue;
|
|
18
|
+
if (!found || entry.data.seq > found.seq || entry.data.generation > found.generation)
|
|
19
|
+
found = entry.data;
|
|
20
|
+
}
|
|
21
|
+
return found;
|
|
22
|
+
}
|
|
23
|
+
export class SwarmSessionState {
|
|
24
|
+
taskId;
|
|
25
|
+
append;
|
|
26
|
+
_generation = 0;
|
|
27
|
+
_seq = 0;
|
|
28
|
+
_snapshot;
|
|
29
|
+
_intents = new Map();
|
|
30
|
+
timers = new Set();
|
|
31
|
+
heartbeatTimer;
|
|
32
|
+
constructor(ctx, taskId, restored) {
|
|
33
|
+
this.taskId = taskId;
|
|
34
|
+
this.append = ctx.appendEntry;
|
|
35
|
+
if (restored) {
|
|
36
|
+
this._generation = restored.generation;
|
|
37
|
+
this._seq = restored.seq;
|
|
38
|
+
this._snapshot = structuredClone(restored.snapshot);
|
|
39
|
+
for (const i of restored.intents)
|
|
40
|
+
this._intents.set(i.op_id, structuredClone(i));
|
|
41
|
+
}
|
|
42
|
+
else
|
|
43
|
+
this._snapshot = this.makeSnapshot();
|
|
44
|
+
}
|
|
45
|
+
startSession() { this._generation++; return this.persist("session_start", { generation: this._generation }); }
|
|
46
|
+
get generation() { return this._generation; }
|
|
47
|
+
get seq() { return this._seq; }
|
|
48
|
+
get intents() { return [...this._intents.values()].map((x) => structuredClone(x)); }
|
|
49
|
+
snapshot() { return structuredClone(this._snapshot); }
|
|
50
|
+
makeSnapshot() { return { protocol: 2, task_id: this.taskId, generation: this._generation, seq: this._seq, agent: "booting", delivery: "none", resource: "detached", lifecycle: "created", recovery: "idle_waiting", pending_intents: [...this._intents.keys()], at: new Date().toISOString() }; }
|
|
51
|
+
persist(kind, patch) { this._seq++; this._snapshot = { ...this._snapshot, ...patch, protocol: 2, task_id: this.taskId, generation: this._generation, seq: this._seq, pending_intents: [...this._intents.values()].filter((i) => i.status !== "accepted").map((i) => i.op_id), at: new Date().toISOString() }; this.append(SWARM_STATE_ENTRY, { protocol: 2, task_id: this.taskId, generation: this._generation, seq: this._seq, snapshot: this._snapshot, intents: this.intents, kind }); return this.snapshot(); }
|
|
52
|
+
transition(patch, kind = "snapshot") { return this.persist(kind, patch); }
|
|
53
|
+
heartbeat() { return this.persist("swarm_heartbeat"); }
|
|
54
|
+
recordIntent(kind, payload, opId) { if (this._intents.has(opId))
|
|
55
|
+
return structuredClone(this._intents.get(opId)); const intent = { op_id: opId, kind, payload, attempts: 0, status: "pending" }; this._intents.set(opId, intent); this.persist(kind, { delivery: "pending" }); return structuredClone(intent); }
|
|
56
|
+
receipt(opId, receipt) { const i = this._intents.get(opId); if (!i)
|
|
57
|
+
return false; i.status = "accepted"; i.receipt = receipt; this.persist("intent_receipt", { delivery: "accepted", lifecycle: i.kind === "swarm_complete" ? "exited" : this._snapshot.lifecycle }); return true; }
|
|
58
|
+
async retry(opId, send, maxAttempts = 3) { const i = this._intents.get(opId); if (!i)
|
|
59
|
+
throw new Error(`unknown intent ${opId}`); for (let n = i.attempts; n < maxAttempts && i.status !== "accepted"; n++) {
|
|
60
|
+
i.attempts++;
|
|
61
|
+
i.status = "retrying";
|
|
62
|
+
this.persist("intent_attempt", { delivery: "retrying" });
|
|
63
|
+
try {
|
|
64
|
+
const receipt = await send(structuredClone(i));
|
|
65
|
+
this.receipt(opId, receipt);
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
i.last_error = e instanceof Error ? e.message : String(e);
|
|
69
|
+
if (i.attempts >= maxAttempts) {
|
|
70
|
+
i.status = "exhausted";
|
|
71
|
+
this.persist("intent_exhausted", { delivery: "exhausted", recovery: "idle_fault" });
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
const delay = 1000 * 2 ** (i.attempts - 1);
|
|
75
|
+
await new Promise((resolve) => { const t = setTimeout(() => { this.timers.delete(t); resolve(); }, delay); this.timers.add(t); });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} return structuredClone(i); }
|
|
79
|
+
startHeartbeat(intervalMs = 10_000, run = () => this.heartbeat()) {
|
|
80
|
+
this.stopHeartbeat();
|
|
81
|
+
this.heartbeatTimer = setInterval(() => run(), Math.max(1, intervalMs));
|
|
82
|
+
this.heartbeatTimer.unref?.();
|
|
83
|
+
}
|
|
84
|
+
stopHeartbeat() { if (this.heartbeatTimer)
|
|
85
|
+
clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; }
|
|
86
|
+
shutdown() { this.stopHeartbeat(); for (const t of this.timers)
|
|
87
|
+
clearTimeout(t); this.timers.clear(); }
|
|
88
|
+
}
|
|
89
|
+
export function compareSwarmVersion(a, b) { if (a.generation < b.generation || (a.generation === b.generation && a.seq < b.seq))
|
|
90
|
+
return "stale"; if (a.generation === b.generation && a.seq === b.seq)
|
|
91
|
+
return "duplicate"; return "new"; }
|
package/dist/swarm-slot.d.ts
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
export type SwarmHandleResult = "claimed" | "yielded" | "not-swarm";
|
|
2
2
|
export interface SwarmPi {
|
|
3
|
-
|
|
4
|
-
customType: string;
|
|
5
|
-
content: string;
|
|
6
|
-
display: boolean;
|
|
7
|
-
details?: unknown;
|
|
8
|
-
}, opts: {
|
|
9
|
-
triggerTurn: true;
|
|
3
|
+
sendUserMessage: (content: string, opts: {
|
|
10
4
|
deliverAs: "followUp";
|
|
11
5
|
}) => void;
|
|
12
6
|
}
|
package/dist/swarm-slot.js
CHANGED
|
@@ -15,32 +15,35 @@ export class SwarmSlot {
|
|
|
15
15
|
const parsed = parseSwarmHeader(text);
|
|
16
16
|
if (!parsed)
|
|
17
17
|
return "not-swarm";
|
|
18
|
-
const { header, payload } = parsed;
|
|
18
|
+
const { header, delivery, payload } = parsed;
|
|
19
|
+
// A scheduler-created terminal carries ONLYNE_SWARM_TASK. Its task id
|
|
20
|
+
// is a hard delivery binding: history replay or a stale live event for
|
|
21
|
+
// another task must never claim this session. Without this guard a
|
|
22
|
+
// freshly spawned pane can run an expired workspace-local payload
|
|
23
|
+
// before its assigned FIFO write reaches history.
|
|
24
|
+
if (preferredTaskId && header.task_id !== preferredTaskId)
|
|
25
|
+
return "not-swarm";
|
|
26
|
+
// Only the scheduler's second delivery is claimable. A worker's raw
|
|
27
|
+
// swarm_send relay and every out wire omit this structured field, so
|
|
28
|
+
// history catchup cannot claim either one as an executable task.
|
|
29
|
+
if (delivery !== "scheduler")
|
|
30
|
+
return "not-swarm";
|
|
19
31
|
if (!this.taskId) {
|
|
20
32
|
this.taskId = header.task_id;
|
|
21
33
|
this.from = header.from;
|
|
22
34
|
this.transfer = header.transfer_send_to;
|
|
23
35
|
this.attempt = header.attempt;
|
|
24
36
|
this.sentChildIds = [];
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
|
|
37
|
+
// A scheduler delivery is a real user task. `sendUserMessage` always
|
|
38
|
+
// triggers a turn, including during a fresh Pi session; custom
|
|
39
|
+
// `sendMessage` follow-ups can remain queued until a later event while
|
|
40
|
+
// the session_start hook is still settling.
|
|
41
|
+
pi.sendUserMessage(resolveSwarmPrompt(workspaceRoot ?? process.cwd(), header, payload) ?? payload, { deliverAs: "followUp" });
|
|
29
42
|
return "claimed";
|
|
30
43
|
}
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// slot silently — the stale claim never did work, so nothing is lost.
|
|
35
|
-
if (preferredTaskId && header.task_id === preferredTaskId && this.taskId !== preferredTaskId) {
|
|
36
|
-
this.taskId = header.task_id;
|
|
37
|
-
this.from = header.from;
|
|
38
|
-
this.transfer = header.transfer_send_to;
|
|
39
|
-
this.attempt = header.attempt;
|
|
40
|
-
this.sentChildIds = [];
|
|
41
|
-
pi.sendMessage({ customType: "onlyne-swarm-task", content: resolveSwarmPrompt(workspaceRoot ?? process.cwd(), header, payload) ?? payload, display: true }, { triggerTurn: true, deliverAs: "followUp" });
|
|
42
|
-
return "yielded";
|
|
43
|
-
}
|
|
44
|
+
// A claimed session normally receives no further delivery. The
|
|
45
|
+
// preferred-id guard above already protected an env-bound pane, so this
|
|
46
|
+
// branch only applies to manual ready-pool sessions with no env task.
|
|
44
47
|
return "not-swarm";
|
|
45
48
|
}
|
|
46
49
|
task() {
|
package/dist/swarm.d.ts
CHANGED
|
@@ -5,10 +5,37 @@ export interface SwarmHeader {
|
|
|
5
5
|
transfer_send_to: string;
|
|
6
6
|
attempt: number;
|
|
7
7
|
}
|
|
8
|
+
/** `scheduler` identifies the scheduler's second delivery. Raw relay and out
|
|
9
|
+
* wires omit it, so a cold-start history scan cannot claim them. */
|
|
8
10
|
export interface SwarmMessage {
|
|
9
11
|
header: SwarmHeader;
|
|
12
|
+
delivery: string;
|
|
10
13
|
payload: string;
|
|
11
14
|
}
|
|
15
|
+
/** Frame header for versioned lifecycle evidence consumed by the scheduler
|
|
16
|
+
* reducer. A distinct prefix from `---swarm` / `---swarm-ctl`, so a report
|
|
17
|
+
* never enters the task delivery paths on either side. */
|
|
18
|
+
export declare const SWARM_REPORT_PREFIX = "---swarm-report\n";
|
|
19
|
+
/** Report kinds accepted by the scheduler's report parser. */
|
|
20
|
+
export declare const SWARM_REPORT_KINDS: readonly ["heartbeat", "ready", "turn_started", "snapshot", "complete", "fault"];
|
|
21
|
+
export type SwarmReportKind = (typeof SWARM_REPORT_KINDS)[number];
|
|
22
|
+
/** Whether a value carries scheduler task identity: a UUID. The session's own
|
|
23
|
+
* composite keys (`session:<handle>`) name no scheduler row, so a report built
|
|
24
|
+
* from one would land as unknown-session noise on that side. */
|
|
25
|
+
export declare function isSwarmTaskId(value: string): boolean;
|
|
26
|
+
/** Whether a loopback text is a lifecycle report frame. Reports live in the
|
|
27
|
+
* same loopback history and event stream as task wires, so every task scan
|
|
28
|
+
* skips them ahead of its malformed-header check. */
|
|
29
|
+
export declare function isSwarmReportFrame(text: string): boolean;
|
|
30
|
+
/** Render one lifecycle report frame:
|
|
31
|
+
* `---swarm-report\nprotocol: 2\nop: <kind>\ntask_id: <uuid>\ngeneration: <n>\nseq: <n>\n---\n`
|
|
32
|
+
* Field order, labels, values, and the closing delimiter match the scheduler
|
|
33
|
+
* parser byte for byte; its `fields()` rejects missing, unknown, and duplicate
|
|
34
|
+
* keys, so the frame carries exactly those five fields. The trailing `---\n`
|
|
35
|
+
* closes the header region the parser reads.
|
|
36
|
+
* Invalid input fails here with a diagnostic: a synthetic task id and an
|
|
37
|
+
* out-of-range counter never reach the wire. */
|
|
38
|
+
export declare function renderSwarmReport(kind: SwarmReportKind, taskId: string, generation: number, seq: number): string;
|
|
12
39
|
/** Parse a `---swarm` body header. Returns null for non-swarm messages. */
|
|
13
40
|
export declare function parseSwarmHeader(text: string): SwarmMessage | null;
|
|
14
41
|
/** Downlink control wire (scheduler -> session). Header-only marker the
|
|
@@ -38,5 +65,32 @@ export declare function readSwarmModel(onlyneDir: string): {
|
|
|
38
65
|
export declare function treePathForWorkspace(workspaceRoot: string): string;
|
|
39
66
|
/** Terminal handle for swarm_ready correlation (injected by the scheduler). */
|
|
40
67
|
export declare function terminalHandle(): string;
|
|
68
|
+
/** Choose a scheduler-delivered history wire for a fresh swarm session.
|
|
69
|
+
*
|
|
70
|
+
* A terminal with ONLYNE_SWARM_TASK has one assigned task. History may still
|
|
71
|
+
* contain arbitrary old unclosed wires for the same workspace, so it may
|
|
72
|
+
* replay only that exact id. If the assigned delivery has not reached history
|
|
73
|
+
* yet, return undefined and let the live inbound event deliver it. Sessions
|
|
74
|
+
* without an env assignment (manual ready pool) retain newest-unclosed
|
|
75
|
+
* fallback behavior. This is pure so the replay race is regression-tested.
|
|
76
|
+
*/
|
|
77
|
+
export declare function selectHistoryDelivery(expectedTaskId: string | undefined, inbounds: readonly string[], outs: ReadonlySet<string>): string | undefined;
|
|
41
78
|
/** Task id injected by the scheduler via env (fallback before swarm_ready handshake). */
|
|
42
79
|
export declare function envTaskId(): string;
|
|
80
|
+
/** R4 hop-activity decision, pure so the guard and the pending_exit bit are
|
|
81
|
+
* unit-tested without a live daemon. Returns the daemon op body, or null when
|
|
82
|
+
* nothing should be reported: busy/idle only make sense for a swarm session
|
|
83
|
+
* that has a workspace and a live claimed task. An idle report always carries
|
|
84
|
+
* pending_exit=true — the plugin never sends idle after swarm_complete cleared
|
|
85
|
+
* the task (that path writes no event; the scheduler owns the recycle). */
|
|
86
|
+
export declare function hopActivityBody(kind: "busy" | "idle", opts: {
|
|
87
|
+
swarm: boolean;
|
|
88
|
+
workspaceRoot?: string;
|
|
89
|
+
terminal: string;
|
|
90
|
+
taskId?: string;
|
|
91
|
+
}): {
|
|
92
|
+
workspace: string;
|
|
93
|
+
terminal_handle: string;
|
|
94
|
+
task_id: string;
|
|
95
|
+
pending_exit?: boolean;
|
|
96
|
+
} | null;
|