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/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 a full `Observation` (`onlyne-session`'s
115
- * reducer type), not a loose status string: the host deserialises it and rejects
116
- * anything that is not a legal state tuple.
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
- * A legal `Observation` for one agent state.
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
- * `onlyne-session`'s `is_legal` requires `public` to be `project(...)` of the
197
- * other dimensions and non-zero reconcile policy, so the tuple is built rather
198
- * than passed through: the plugin owns the agent dimension (the host never
199
- * synthesises turn state), and leaves delivery at `none`/outcome `pending`,
200
- * which is its own truth until it reports a completion.
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
- * `host` is where this process runs (`hostBinding`); it is attached only when
203
- * the environment names a pane, so a pi outside Orca reports a tuple with no
204
- * host field at all.
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) {
@@ -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
- // Mirrors onlyne-session's `project`: running works, idle/ready wait, booting creates.
138
- const expected = { booting: "created", ready: "idle", running: "working", idle: "idle", gone: "created" };
139
- for (const [agent, projected] of Object.entries(expected)) {
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.equal(observed.public, projected, `agent=${agent}`);
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
- assert.equal(observed.outcome, "pending");
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
- assert.match(text, /^\[onlyne\] task 11111111-1111-4111-8111-111111111111 from role:planner \(kind task\)/);
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: {} } },