pi-onlyne 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -34
- package/README.zh.md +47 -26
- package/package.json +1 -1
- package/src/agent.mjs +175 -49
- package/src/agent.test.mjs +245 -31
- package/src/config.mjs +27 -5
- package/src/index.ts +35 -3
- package/src/protocol.mjs +41 -49
- package/src/protocol.test.mjs +58 -8
package/src/protocol.mjs
CHANGED
|
@@ -111,9 +111,13 @@ export function readyReport({ taskId, sessionId, generation, seq }) {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
|
-
* `report.heartbeat`. `observed` is
|
|
115
|
-
*
|
|
116
|
-
*
|
|
114
|
+
* `report.heartbeat`. `observed` is an `Observation` (`onlyne-session`'s reducer
|
|
115
|
+
* type), not a loose status string: the host deserialises the tuple, overwrites
|
|
116
|
+
* the six dimensions it owns — `delivery` and `recovery` from its intent drain
|
|
117
|
+
* and reducer history, `generation_live` and the reconcile tuning with its
|
|
118
|
+
* counter from the role's own records — and repairs whatever pairing that leaves
|
|
119
|
+
* before the reducer reads it. What
|
|
120
|
+
* this plugin puts into the body is `observationFor`'s exact key set.
|
|
117
121
|
*/
|
|
118
122
|
export function heartbeatReport({ taskId, generation, seq, agent, host = null }) {
|
|
119
123
|
return {
|
|
@@ -127,40 +131,6 @@ export function heartbeatReport({ taskId, generation, seq, agent, host = null })
|
|
|
127
131
|
};
|
|
128
132
|
}
|
|
129
133
|
|
|
130
|
-
/**
|
|
131
|
-
* The final observation of a settled session: `agent: idle` beside the outcome
|
|
132
|
-
* the completion just stated.
|
|
133
|
-
*
|
|
134
|
-
* A session that only ever reported `running` and then completed leaves the
|
|
135
|
-
* ledger's projection saying `running` forever, because nothing observes the
|
|
136
|
-
* exit. This body is the tuple the host's own settle produces
|
|
137
|
-
* (`onlyne-session/src/reconcile.rs::settle_body`) with the agent dimension
|
|
138
|
-
* moved to `idle`, so `is_legal` accepts it: `outcome: done` requires
|
|
139
|
-
* `delivery: accepted` and an idle agent requires `recovery: draining`, and any
|
|
140
|
-
* other outcome carries the delivery unchanged.
|
|
141
|
-
*/
|
|
142
|
-
export function settledReport({ taskId, outcome, generation, seq, host = null }) {
|
|
143
|
-
const normalized = normalizeOutcome(outcome);
|
|
144
|
-
const done = normalized === "done";
|
|
145
|
-
const observed = {
|
|
146
|
-
version: { generation, seq },
|
|
147
|
-
generation_live: true,
|
|
148
|
-
isolate_after: 1,
|
|
149
|
-
terminate_after: 3,
|
|
150
|
-
mismatch_count: 0,
|
|
151
|
-
agent: "idle",
|
|
152
|
-
delivery: done ? "accepted" : "none",
|
|
153
|
-
resource: "attached",
|
|
154
|
-
recovery: done ? "draining" : "none",
|
|
155
|
-
outcome: normalized,
|
|
156
|
-
// `project(idle, accepted, …, done)` is `exited`; every other outcome keeps
|
|
157
|
-
// the session `working` until its resource closes.
|
|
158
|
-
public: done ? "exited" : "working",
|
|
159
|
-
};
|
|
160
|
-
if (host) observed.host = host;
|
|
161
|
-
return { kind: "heartbeat", data: { task_id: taskId, generation, seq, observed } };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
134
|
/** `report.complete` — the terminal fact the ledger keeps. */
|
|
165
135
|
export function completeReport({ taskId, outcome, head }) {
|
|
166
136
|
const report = { kind: "complete", data: { task_id: taskId, outcome: normalizeOutcome(outcome) } };
|
|
@@ -191,17 +161,29 @@ export function detachArgs(reason) {
|
|
|
191
161
|
}
|
|
192
162
|
|
|
193
163
|
/**
|
|
194
|
-
*
|
|
164
|
+
* The observation for one agent state: the plugin's own report, on the wire as
|
|
165
|
+
* the `observed` body of a heartbeat.
|
|
195
166
|
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
167
|
+
* The plugin states three things and only three: the `agent` dimension (its turn
|
|
168
|
+
* hooks are the only witness), `resource: attached` — the process is running in
|
|
169
|
+
* the pane, which is the attach the host's dispatch path recorded — and the
|
|
170
|
+
* `host` binding. `delivery`, `recovery`, `generation_live`, `isolate_after`,
|
|
171
|
+
* `terminate_after` and `mismatch_count` are placeholders with a reason:
|
|
172
|
+
* `Observation` has no optional dimensions, the body must deserialize, and the
|
|
173
|
+
* client rewrites all six from its own records before the reducer reads them
|
|
174
|
+
* (`crates/onlyne-client/src/session/dispatch/reports.rs`) — the completion
|
|
175
|
+
* intent and its recovery label are the client's, the reconcile tuning and the
|
|
176
|
+
* counter beside it are the role's — so what the plugin sends there is never
|
|
177
|
+
* believed. Neither the task's outcome nor a public view
|
|
178
|
+
* belongs in a tuple any more — the ledger owns the result, `project` derives
|
|
179
|
+
* the view — so neither is sent.
|
|
201
180
|
*
|
|
202
|
-
* `
|
|
203
|
-
*
|
|
204
|
-
*
|
|
181
|
+
* `gone` travels as `booting` on purpose: only the host's reconnect-grace window
|
|
182
|
+
* declares a session dead, and a beat that pre-declared `gone` would bury the
|
|
183
|
+
* row's agent before that window has run.
|
|
184
|
+
*
|
|
185
|
+
* `host` is attached only when the environment names a pane, so a pi outside
|
|
186
|
+
* Orca reports a tuple with no host field at all.
|
|
205
187
|
* @param {"booting"|"ready"|"running"|"idle"|"gone"} agent
|
|
206
188
|
*/
|
|
207
189
|
export function observationFor(agent, { generation, seq, host = null }) {
|
|
@@ -217,8 +199,6 @@ export function observationFor(agent, { generation, seq, host = null }) {
|
|
|
217
199
|
delivery: "none",
|
|
218
200
|
resource: "attached",
|
|
219
201
|
recovery: "none",
|
|
220
|
-
outcome: "pending",
|
|
221
|
-
public: state === "running" ? "working" : state === "ready" || state === "idle" ? "idle" : "created",
|
|
222
202
|
};
|
|
223
203
|
if (host) observed.host = host;
|
|
224
204
|
return observed;
|
|
@@ -331,13 +311,25 @@ export function normalizeOutcome(value) {
|
|
|
331
311
|
* session transcript shows where the instruction came from; the role prose
|
|
332
312
|
* (identical in `welcome` and `assign`) is folded in only when it has not
|
|
333
313
|
* already been delivered.
|
|
314
|
+
*
|
|
315
|
+
* The header also carries the family's own figures — the hop this assignment
|
|
316
|
+
* sits at and the hops the family may spend — so a role reads its position off
|
|
317
|
+
* the instruction. Both appear only when the causality names a hop budget: the
|
|
318
|
+
* budget is what marks a payload as a member of a bounded family, and a payload
|
|
319
|
+
* that names none injects exactly the bytes it produced before this header
|
|
320
|
+
* carried them.
|
|
334
321
|
* @param {{ assign: any, proseIsNew: boolean, attachmentPaths?: string[] }} options
|
|
335
322
|
*/
|
|
336
323
|
export function injectionText({ assign, proseIsNew, attachmentPaths = [] }) {
|
|
337
324
|
const envelope = assign.envelope ?? {};
|
|
325
|
+
const causality = envelope.causality ?? {};
|
|
338
326
|
const taskId = assign.task_id ?? envelope.causality?.task ?? "unknown";
|
|
327
|
+
const position =
|
|
328
|
+
typeof causality.hop_budget === "number"
|
|
329
|
+
? `, hop ${causality.hop ?? 0}, hop budget ${causality.hop_budget}`
|
|
330
|
+
: "";
|
|
339
331
|
const lines = [
|
|
340
|
-
`[onlyne] task ${taskId} from ${describePrincipal(envelope.from)} (kind ${envelope.kind ?? "task"})`,
|
|
332
|
+
`[onlyne] task ${taskId} from ${describePrincipal(envelope.from)} (kind ${envelope.kind ?? "task"}${position})`,
|
|
341
333
|
];
|
|
342
334
|
const prose = typeof assign.prose === "string" ? assign.prose.trim() : "";
|
|
343
335
|
if (prose && proseIsNew) {
|
package/src/protocol.test.mjs
CHANGED
|
@@ -128,22 +128,38 @@ test("heartbeat carries a full legal observation", () => {
|
|
|
128
128
|
delivery: "none",
|
|
129
129
|
resource: "attached",
|
|
130
130
|
recovery: "none",
|
|
131
|
-
outcome: "pending",
|
|
132
|
-
public: "working",
|
|
133
131
|
});
|
|
134
132
|
});
|
|
135
133
|
|
|
136
134
|
test("every observation is a state tuple the reducer calls legal", () => {
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
135
|
+
// The tuple carries no derived view and no task result any more: `Observation`
|
|
136
|
+
// dropped `public` and `outcome`, so the projection mapping that once had to be
|
|
137
|
+
// mirrored here is gone, and the key set is the whole contract.
|
|
138
|
+
const keys = [
|
|
139
|
+
"version",
|
|
140
|
+
"generation_live",
|
|
141
|
+
"isolate_after",
|
|
142
|
+
"terminate_after",
|
|
143
|
+
"mismatch_count",
|
|
144
|
+
"agent",
|
|
145
|
+
"delivery",
|
|
146
|
+
"resource",
|
|
147
|
+
"recovery",
|
|
148
|
+
];
|
|
149
|
+
const agents = { booting: "booting", ready: "ready", running: "running", idle: "idle", gone: "booting" };
|
|
150
|
+
for (const [agent, state] of Object.entries(agents)) {
|
|
140
151
|
const observed = observationFor(agent, { generation: 1, seq: 1 });
|
|
141
|
-
assert.
|
|
152
|
+
assert.deepEqual(Object.keys(observed), keys, `agent=${agent}`);
|
|
153
|
+
assert.equal(observed.agent, state, `agent=${agent}: gone travels as booting; only the host's grace declares death`);
|
|
142
154
|
assert.notEqual(observed.isolate_after, 0);
|
|
143
155
|
assert.notEqual(observed.terminate_after, 0);
|
|
144
|
-
|
|
156
|
+
// The plugin can see neither the intent drain nor its own resource's close,
|
|
157
|
+
// and it does not pretend otherwise: the two placeholders below are
|
|
158
|
+
// overwritten by the client's composition, and `resource: attached` is the
|
|
159
|
+
// attach the host's dispatch path already recorded.
|
|
145
160
|
assert.equal(observed.delivery, "none");
|
|
146
161
|
assert.equal(observed.recovery, "none");
|
|
162
|
+
assert.equal(observed.resource, "attached");
|
|
147
163
|
}
|
|
148
164
|
});
|
|
149
165
|
|
|
@@ -238,7 +254,12 @@ test("the ledger head is capped at the plan's 200 characters", () => {
|
|
|
238
254
|
test("an assignment becomes one message naming its origin and payload", { skip: !hasVectors }, () => {
|
|
239
255
|
const assign = ASSIGN().args;
|
|
240
256
|
const text = injectionText({ assign, proseIsNew: true });
|
|
241
|
-
|
|
257
|
+
// The vector's causality names no hop budget, and the header is byte for byte
|
|
258
|
+
// the line this plugin has always injected.
|
|
259
|
+
assert.equal(
|
|
260
|
+
text.split("\n")[0],
|
|
261
|
+
"[onlyne] task 11111111-1111-4111-8111-111111111111 from role:planner (kind task)",
|
|
262
|
+
);
|
|
242
263
|
assert.match(text, /\[onlyne\] role prose from the spec:\nRead the incoming task/);
|
|
243
264
|
assert.match(text, /\nbuild it\n?$/);
|
|
244
265
|
|
|
@@ -247,6 +268,35 @@ test("an assignment becomes one message naming its origin and payload", { skip:
|
|
|
247
268
|
assert.match(repeat, /build it/);
|
|
248
269
|
});
|
|
249
270
|
|
|
271
|
+
test("the header names the hop and the budget once the family names a budget", () => {
|
|
272
|
+
const header = (causality) =>
|
|
273
|
+
injectionText({
|
|
274
|
+
assign: {
|
|
275
|
+
task_id: "t9",
|
|
276
|
+
envelope: {
|
|
277
|
+
id: "e9",
|
|
278
|
+
kind: "task",
|
|
279
|
+
from: { role: { role: "planner" } },
|
|
280
|
+
causality,
|
|
281
|
+
body: { text: "pass it on" },
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
proseIsNew: false,
|
|
285
|
+
}).split("\n")[0];
|
|
286
|
+
|
|
287
|
+
assert.equal(
|
|
288
|
+
header({ task: "t9", hop: 2, attempt: 0, family: "t1", hop_budget: 5 }),
|
|
289
|
+
"[onlyne] task t9 from role:planner (kind task, hop 2, hop budget 5)",
|
|
290
|
+
);
|
|
291
|
+
// A family may spend no further hop: zero is a figure the header carries.
|
|
292
|
+
assert.equal(
|
|
293
|
+
header({ task: "t9", hop: 0, attempt: 0, family: "t1", hop_budget: 0 }),
|
|
294
|
+
"[onlyne] task t9 from role:planner (kind task, hop 0, hop budget 0)",
|
|
295
|
+
);
|
|
296
|
+
// No budget named: the compat rule holds, hop or no hop.
|
|
297
|
+
assert.equal(header({ task: "t9", hop: 2, attempt: 0 }), "[onlyne] task t9 from role:planner (kind task)");
|
|
298
|
+
});
|
|
299
|
+
|
|
250
300
|
test("an empty-bodied assignment still produces an instruction", () => {
|
|
251
301
|
const text = injectionText({
|
|
252
302
|
assign: { task_id: "t9", envelope: { id: "e9", kind: "note", from: { gateway: { gateway: "fg1", channel: "fake", conversation: "c1" } }, body: {} } },
|