pi-onlyne 1.2.0 → 1.2.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/src/agent.mjs CHANGED
@@ -175,8 +175,6 @@ export class OnlyneAgent {
175
175
  this.lastError = null;
176
176
  /** Whether pi has already been asked to end this process (`exitSession`). */
177
177
  this.exitRequested = false;
178
- /** Agent phase of the last heartbeat this connection sent, if any. */
179
- this.lastPhase = null;
180
178
  this.stats = { assigns: 0, duplicates: 0, injections: 0, completions: 0, reports: 0, reconnects: 0, recycles: 0 };
181
179
  }
182
180
 
@@ -325,9 +323,10 @@ export class OnlyneAgent {
325
323
  }
326
324
  this.welcome = welcome;
327
325
  this.connected = true;
328
- // A fresh connection has reported nothing: the next beat is news.
329
- this.lastPhase = null;
330
- this.agentState = this.tasks.size > 0 ? "idle" : "ready";
326
+ // A fresh connection has reported nothing, so the phase is a question, not
327
+ // an answer: the first beat below re-derives it from pi. A taskless session
328
+ // is the ready pool, and ready is its own state.
329
+ this.agentState = this.tasks.size > 0 ? await this.derivedPhase() : "ready";
331
330
  this.activity.set({
332
331
  role: welcome.role,
333
332
  connection: "connected",
@@ -500,7 +499,10 @@ export class OnlyneAgent {
500
499
  }
501
500
 
502
501
  /**
503
- * One heartbeat for the task this connection serves.
502
+ * One heartbeat for the task this connection serves. The phase is the one the
503
+ * rule names: `idle` only while the session waits for user input, `running`
504
+ * for everything else, re-derived from pi on every beat so a phase that went
505
+ * stale when a run started again cannot survive a tick.
504
506
  *
505
507
  * A task this plugin already completed gets none: the beat is a full
506
508
  * snapshot of what the plugin can see, and after the completion the agent's
@@ -512,22 +514,40 @@ export class OnlyneAgent {
512
514
  * heartbeat left over from the finishing turn landed three milliseconds
513
515
  * behind the completion.
514
516
  */
515
- async heartbeat(agent = this.agentState) {
517
+ async heartbeat(agent = null) {
516
518
  if (!this.connected) return;
517
519
  const taskId = this.activeTaskId();
518
520
  if (!taskId) return;
519
521
  if (this.tasks.get(taskId)?.completed) return;
520
- this.agentState = agent;
522
+ const phase = agent ?? (await this.derivedPhase());
523
+ this.agentState = phase;
521
524
  this.seq += 1;
522
525
  await this.request("report", heartbeatReport({
523
526
  taskId,
524
527
  generation: this.generation,
525
528
  seq: this.seq,
526
- agent,
529
+ agent: phase,
527
530
  host: this.host,
528
531
  }));
529
532
  this.stats.reports += 1;
530
- this.lastPhase = agent;
533
+ }
534
+
535
+ /**
536
+ * Where the session actually is, in the only two words the wire has for it.
537
+ * The surface asks pi, and adds the one case pi cannot see: work a
538
+ * background-task extension took off the agent loop.
539
+ * @returns {Promise<"idle"|"running">}
540
+ */
541
+ async derivedPhase() {
542
+ try {
543
+ const waiting = await this.surface.waitingForInput?.();
544
+ return waiting === true ? "idle" : "running";
545
+ } catch (error) {
546
+ // A surface that cannot answer has not witnessed a session waiting for
547
+ // input, and the rule reads that as running.
548
+ this.log(`input-waiting probe failed: ${error.message}`);
549
+ return "running";
550
+ }
531
551
  }
532
552
 
533
553
  /** Tasks still owing a completion; a finished task keeps its record. */
@@ -619,6 +639,7 @@ export class OnlyneAgent {
619
639
  held.turnsSinceAssign = 0;
620
640
  held.reminders = 0;
621
641
  held.remindedAt = 0;
642
+ held.reminderWokeTurn = false;
622
643
  held.errored = false;
623
644
  held.envelopeId = envelope.id ?? held.envelopeId;
624
645
  held.assignment = assignment;
@@ -640,6 +661,8 @@ export class OnlyneAgent {
640
661
  reminders: 0,
641
662
  /** The `turnsSinceAssign` the last reminder was charged to. */
642
663
  remindedAt: 0,
664
+ /** Whether the next turn is the one this plugin's own reminder woke. */
665
+ reminderWokeTurn: false,
643
666
  });
644
667
  }
645
668
  this.agentState = "running";
@@ -702,42 +725,65 @@ export class OnlyneAgent {
702
725
 
703
726
  // ------------------------------------------------------------ pi → plugin
704
727
 
705
- /** A turn started: the plugin's own agent fact is `running`. */
728
+ /**
729
+ * A turn started: the plugin's own agent fact is `running`, and the idle
730
+ * ladder starts over. The count belongs to one idle episode, so a session
731
+ * that ran again owns a fresh bound; the one turn this plugin's own reminder
732
+ * woke belongs to the episode that reminder belongs to, and keeps it.
733
+ */
706
734
  onTurnStart() {
707
- const task = [...this.tasks.values()].find((item) => !item.completed);
708
- if (task) task.turns += 1;
735
+ for (const task of this.tasks.values()) {
736
+ if (task.completed) continue;
737
+ task.turns += 1;
738
+ if (task.reminderWokeTurn) {
739
+ task.reminderWokeTurn = false;
740
+ continue;
741
+ }
742
+ task.turnsSinceAssign = 0;
743
+ task.reminders = 0;
744
+ task.remindedAt = 0;
745
+ }
709
746
  void this.heartbeat("running").catch((error) => this.log(`heartbeat refused: ${error.message}`));
710
747
  }
711
748
 
712
- /** A turn ended: the agent is idle, and the settle window starts. */
749
+ /**
750
+ * A turn ended: one turn of a run that may still have more, and the settle
751
+ * window starts. The phase is re-derived, so the beat says `running` while pi
752
+ * keeps working and says `idle` only once the session waits for input.
753
+ */
713
754
  onTurnEnd() {
714
755
  for (const task of this.tasks.values()) {
715
756
  if (!task.completed) task.turnsSinceAssign += 1;
716
757
  }
717
- void this.heartbeat("idle").catch((error) => this.log(`heartbeat refused: ${error.message}`));
758
+ void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
718
759
  this.armSettleFallback();
719
760
  }
720
761
 
721
- /** pi will not continue on its own: take the settle decision for this idle. */
762
+ /**
763
+ * pi has settled: this is the moment the rule names as waiting for input, and
764
+ * the settle decision belongs to it.
765
+ */
722
766
  onSettled() {
723
767
  this.clearSettleFallback();
724
- this.trySettle();
768
+ void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
769
+ void this.trySettle().catch((error) => this.log(`settle decision failed: ${error.message}`));
725
770
  }
726
771
 
727
772
  /**
728
773
  * The one settle decision. pi keeps `isIdle()` false while it is running,
729
- * retrying, compacting, or holding a queued continuation, so a settle signal
730
- * that arrives during any of those waits instead of deciding on a session
774
+ * retrying, compacting, or holding a queued continuation, and a background task
775
+ * keeps work running after pi itself has settled, so a settle signal that
776
+ * arrives during any of those waits re-arms instead of deciding on a session
731
777
  * that is still busy. The fallback timer and `agent_settled` both land here,
732
778
  * and `settleNow` makes the decision idempotent for one idle episode.
733
779
  */
734
- trySettle() {
780
+ async trySettle() {
735
781
  if (this.closed || this.activeTasks().length === 0) return;
736
- if (this.surface.isIdle?.() === false) {
782
+ if (await this.derivedPhase() !== "idle") {
737
783
  this.armSettleFallback();
738
784
  return;
739
785
  }
740
- void this.settleNow().catch((error) => this.log(`settle decision failed: ${error.message}`));
786
+ await this.settleNow().catch((error) => this.log(`settle decision failed: ${error.message}`));
741
787
  }
742
788
 
743
789
  /** A failed turn: the task's outcome is `failed`, with the error as its head. */
@@ -747,7 +793,7 @@ export class OnlyneAgent {
747
793
  task.errored = true;
748
794
  if (text) task.head = headOf(text);
749
795
  }
750
- this.trySettle();
796
+ void this.trySettle().catch((error) => this.log(`settle decision failed: ${error.message}`));
751
797
  }
752
798
 
753
799
  /**
@@ -774,7 +820,7 @@ export class OnlyneAgent {
774
820
  if (this.closed || this.activeTasks().length === 0) return;
775
821
  this.settleHandle = this.timer.set(() => {
776
822
  this.settleHandle = null;
777
- this.trySettle();
823
+ void this.trySettle().catch((error) => this.log(`settle decision failed: ${error.message}`));
778
824
  }, this.settleFallbackMs);
779
825
  }
780
826
 
@@ -844,6 +890,9 @@ export class OnlyneAgent {
844
890
  */
845
891
  remind(task) {
846
892
  task.reminders += 1;
893
+ // The turn this reminder is about to wake belongs to the same episode, so
894
+ // `onTurnStart` spends no reset on it and the bound stays reachable.
895
+ task.reminderWokeTurn = true;
847
896
  const rung = `reminder ${task.reminders} of ${this.idleReminders}`;
848
897
  const text = [
849
898
  `[onlyne] your turn ended without a completion exit; this task is still open (${rung}). Call onlyne_complete when it is finished.`,
@@ -935,11 +984,6 @@ export class OnlyneAgent {
935
984
  this.activity.set({ taskId: this.activeTaskId() ?? null, phase: normalized });
936
985
  this.notice("out", `complete ${taskId.slice(0, 8)} ${normalized}${summary ? `: ${summary}` : ""}`);
937
986
  if (this.activeTasks().length === 0) {
938
- if (exitProcess) {
939
- await this.reportSettled(taskId).catch((error) =>
940
- this.log(`settled observation refused: ${error.message}`),
941
- );
942
- }
943
987
  this.stopHeartbeat();
944
988
  if (exitProcess) this.exitSession(normalized);
945
989
  }
@@ -961,37 +1005,6 @@ export class OnlyneAgent {
961
1005
  this.surface.exit?.(reason);
962
1006
  }
963
1007
 
964
- /**
965
- * One last observation before the process leaves: the agent dimension at
966
- * `idle`.
967
- *
968
- * The completion settles the row from the tuple the client holds, which still
969
- * says `running` when the turn that finished was the last report sent, and
970
- * nothing observes the process afterwards. This report is what makes an
971
- * exited session read idle. It is skipped when the last beat was already
972
- * idle — the agent dimension is already right — and it is a request for
973
- * the same reason the completion is: the answer is the handover, and a
974
- * failure here must not stop the exit that the durable completion earned.
975
- *
976
- * It carries no completion fact. The outcome belongs to `report.complete`, and
977
- * the drain the completion opens belongs to the client: an observation states
978
- * where the agent is, not what the session's intent has done.
979
- */
980
- async reportSettled(taskId) {
981
- if (!this.connected || this.lastPhase === "idle") return false;
982
- this.seq += 1;
983
- await this.request("report", heartbeatReport({
984
- taskId,
985
- generation: this.generation,
986
- seq: this.seq,
987
- agent: "idle",
988
- host: this.host,
989
- }));
990
- this.stats.reports += 1;
991
- this.lastPhase = "idle";
992
- return true;
993
- }
994
-
995
1008
  async flushPendingCompletion() {
996
1009
  const pending = this.pendingCompletion;
997
1010
  if (!pending || !this.connected) return;
@@ -1002,9 +1015,6 @@ export class OnlyneAgent {
1002
1015
  this.activity.set({ taskId: this.activeTaskId() ?? null, phase: pending.outcome });
1003
1016
  this.notice("out", `complete ${pending.taskId.slice(0, 8)} ${pending.outcome} flushed after reconnect`);
1004
1017
  if (pending.exitProcess && this.activeTasks().length === 0) {
1005
- await this.reportSettled(pending.taskId).catch((error) =>
1006
- this.log(`settled observation refused: ${error.message}`),
1007
- );
1008
1018
  this.exitSession(pending.outcome);
1009
1019
  }
1010
1020
  } catch (error) {
@@ -206,6 +206,7 @@ class FakeHost {
206
206
  /** The effect surface the agent drives, recorded for assertions. */
207
207
  function fakeSurface(options = {}) {
208
208
  const calls = { wakeUser: [], prose: [], entries: [], status: [], exits: [], widget: [] };
209
+ const flag = (value, fallback) => (value === undefined ? fallback : typeof value === "function" ? value() : value);
209
210
  return {
210
211
  calls,
211
212
  available: {
@@ -237,6 +238,9 @@ function fakeSurface(options = {}) {
237
238
  widget: (lines) => calls.widget.push(lines),
238
239
  welcome: () => {},
239
240
  isIdle: () => (options.idle === undefined ? true : options.idle()),
241
+ async waitingForInput() {
242
+ return Boolean(flag(options.waiting, true) && !flag(options.backgroundWork, false));
243
+ },
240
244
  exit: (reason) => calls.exits.push(reason),
241
245
  };
242
246
  }
@@ -345,7 +349,7 @@ test("every heartbeat names the Orca pane this process was spawned in", async ()
345
349
  orca: { pane_key: PANE_KEY, tab_id: PANE_TAB, leaf_id: PANE_LEAF, handle: "term_1" },
346
350
  });
347
351
  // The binding rides beside the state dimensions instead of replacing them.
348
- assert.equal(observed.agent, "running");
352
+ assert.equal(observed.agent, "idle", "the default fake surface is waiting for input");
349
353
  assert.equal(observed.resource, "attached");
350
354
  assert.deepEqual(Object.keys(observed), [...OBSERVED_KEYS, "host"]);
351
355
  assert.equal(observed.version.generation, 1);
@@ -397,7 +401,7 @@ test("a pi outside an Orca pane reports no host at all", async () => {
397
401
 
398
402
  const [observed] = await waitFor(() => (heartbeats(host).length ? heartbeats(host) : null));
399
403
  assert.equal("host" in observed, false, "no pane, no binding");
400
- assert.equal(observed.agent, "running", "the tuple is still a full observation");
404
+ assert.equal(observed.agent, "idle", "the tuple is still a full observation");
401
405
  });
402
406
  });
403
407
 
@@ -584,7 +588,7 @@ test("an assign sharing the hello reply's chunk waits for the welcome", async ()
584
588
  assert.deepEqual(acks[0], { task_id: TASK_ID, accepted: true });
585
589
  });
586
590
 
587
- test("turn hooks report running then idle, and the settle after them re-sends the assignment", async () => {
591
+ test("a waiting turn end reports idle, and the settle after it re-sends the assignment", async () => {
588
592
  const { agent, host, surface } = await startAgent();
589
593
  agent.start();
590
594
  await waitFor(() => host.of("report").length >= 1);
@@ -616,6 +620,29 @@ test("turn hooks report running then idle, and the settle after them re-sends th
616
620
  assert.deepEqual(surface.calls.exits, [], "a task with a rung left is not failed");
617
621
  });
618
622
 
623
+ test("a busy turn end reports no idle beat and does not settle the ladder", async () => {
624
+ const surface = fakeSurface({ waiting: false });
625
+ const { agent, host } = await startAgent({ surface });
626
+ agent.start();
627
+ await waitFor(() => host.of("report").length >= 1);
628
+ host.notify("assign", assignArgs());
629
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
630
+
631
+ agent.onTurnStart();
632
+ await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "running"));
633
+ agent.onTurnEnd();
634
+ agent.onSettled();
635
+ await waitFor(() => heartbeats(host).length >= 3);
636
+
637
+ assert.equal(
638
+ heartbeats(host).some((observed) => observed.agent === "idle"),
639
+ false,
640
+ "the turn-end beat follows the busy surface rather than assuming the hook means idle",
641
+ );
642
+ assert.equal(surface.calls.wakeUser.length, 1, "the ladder waits while the turn is still running");
643
+ assert.deepEqual(surface.calls.exits, []);
644
+ });
645
+
619
646
  // The live case found this ordering too: pi's turn-end hook fires in the same
620
647
  // millisecond as the settle that reports the completion, so the turn-end
621
648
  // heartbeat lands after the completion report. The beat no longer carries a
@@ -640,9 +667,9 @@ test("a turn-end heartbeat after the completion never leaves the plugin", async
640
667
  assert.deepEqual(host.of("report").slice(reports), [], "the completion is the last report");
641
668
  });
642
669
 
643
- test("a busy pi holds the settle decision until it is idle", async () => {
644
- let idle = false;
645
- const surface = fakeSurface({ idle: () => idle });
670
+ test("a busy pi holds the settle decision until it is waiting for input", async () => {
671
+ let waiting = false;
672
+ const surface = fakeSurface({ waiting: () => waiting });
646
673
  const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
647
674
  agent.start();
648
675
  await waitFor(() => host.of("report").length >= 1);
@@ -653,20 +680,64 @@ test("a busy pi holds the settle decision until it is idle", async () => {
653
680
  await new Promise((resolve) => setTimeout(resolve, 80));
654
681
  assert.equal(surface.calls.wakeUser.length, 1, "a busy pi is not reminded");
655
682
 
656
- idle = true;
683
+ waiting = true;
657
684
  const reminded = await waitFor(() =>
658
685
  surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
659
686
  );
660
687
  assert.match(reminded[1].text, /build it/, "the decision waits for the idle it is about");
661
688
  });
662
689
 
690
+ test("queued input keeps the session non-idle and holds the ladder off", async () => {
691
+ let queued = false;
692
+ const surface = fakeSurface({ waiting: () => !queued });
693
+ const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
694
+ agent.start();
695
+ await waitFor(() => host.of("report").length >= 1);
696
+ host.notify("assign", assignArgs());
697
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
698
+
699
+ agent.onTurnStart();
700
+ queued = true;
701
+ agent.onTurnEnd();
702
+ agent.onSettled();
703
+ await waitFor(() => heartbeats(host).at(-1)?.agent === "running");
704
+ await new Promise((resolve) => setTimeout(resolve, 80));
705
+
706
+ assert.equal(surface.calls.wakeUser.length, 1, "a queued continuation is not an idle episode");
707
+ assert.deepEqual(surface.calls.exits, []);
708
+ });
709
+
710
+ test("a live background task holds the ladder off until its task is terminal", async () => {
711
+ let backgroundWork = true;
712
+ const surface = fakeSurface({ backgroundWork: () => backgroundWork });
713
+ const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
714
+ agent.start();
715
+ await waitFor(() => host.of("report").length >= 1);
716
+ host.notify("assign", assignArgs());
717
+ await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
718
+
719
+ agent.onTurnStart();
720
+ agent.onTurnEnd();
721
+ agent.onSettled();
722
+ await waitFor(() => heartbeats(host).at(-1)?.agent === "running");
723
+ await new Promise((resolve) => setTimeout(resolve, 80));
724
+ assert.equal(surface.calls.wakeUser.length, 1, "work off the agent loop keeps the ladder off");
725
+
726
+ backgroundWork = false;
727
+ agent.onSettled();
728
+ const reminded = await waitFor(() =>
729
+ surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
730
+ );
731
+ assert.match(reminded[1].text, /build it/, "the terminal task releases the idle ladder");
732
+ });
733
+
663
734
  // The idle ladder (`docs/SWARM-REFACTOR-GRILLME.md` §4.2): a turn that ends
664
735
  // without a completion exit re-sends the assignment, and the idle that finds the
665
736
  // bound spent fails the task instead of settling it `done`. The bound is the
666
737
  // workspace's (`config.mjs`, two by default) and the count lives on the record
667
738
  // `onAssign` wrote for the task, which is why a task can be reminded twice and
668
739
  // failed on the third idle without the tool ever being called.
669
- test("an idle without a completion is reminded, the idle past the bound fails the task, and a completed turn is not reminded", async () => {
740
+ test("only settled idles spend ladder rungs, ordinary turns reset them, and the bound fails the task", async () => {
670
741
  const { agent, host, surface } = await startAgent();
671
742
  assert.equal(agent.idleReminders, DEFAULT_IDLE_REMINDERS, "the bound is the workspace's, two by default");
672
743
  agent.start();
@@ -683,35 +754,59 @@ test("an idle without a completion is reminded, the idle past the bound fails th
683
754
  await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
684
755
  assert.equal(surface.calls.wakeUser[0].parts.length, 1, "the injection carries the image");
685
756
 
686
- // Two idles, two reminders: the assignment comes back whole — header, task
687
- // text and all — and the model is told why it is seeing it again.
688
- for (const rung of [1, 2]) {
689
- agent.onTurnStart();
690
- agent.onTurnEnd();
691
- agent.onSettled();
692
- const sent = await waitFor(() =>
693
- surface.calls.wakeUser.length === 1 + rung ? surface.calls.wakeUser : null,
694
- );
695
- const reminder = sent.at(-1);
696
- assert.match(reminder.text, new RegExp(`reminder ${rung} of 2`));
697
- assert.match(reminder.text, /build it/, "the reminder carries the task text");
698
- assert.match(
699
- reminder.text,
700
- /\[onlyne\] task 11111111-1111-4111-8111-111111111111 from role:planner \(kind task\)/,
701
- "and the identity and source the injection named",
702
- );
703
- assert.match(reminder.text, /\[onlyne\] attachment saved to: .*shot\.png/, "the path travels as text");
704
- assert.deepEqual(reminder.parts, [], "and the image itself is not handed over twice");
705
- assert.doesNotMatch(reminder.text, /Read the incoming task/, "the role prose is already in the context");
706
- assert.deepEqual(
707
- host.of("report").filter((report) => report.kind === "complete"),
708
- [],
709
- `rung ${rung}: an open task is not settled while it has a rung left`,
710
- );
711
- }
757
+ // The first turn belongs to the session itself. Its settled idle spends rung
758
+ // one. The turn that reminder wakes does not reset the episode, so its own
759
+ // settled idle spends rung two rather than starting over.
760
+ agent.onTurnStart();
761
+ agent.onTurnEnd();
762
+ agent.onSettled();
763
+ const first = await waitFor(() =>
764
+ surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
765
+ );
766
+ const firstReminder = first.at(-1);
767
+ assert.match(firstReminder.text, /reminder 1 of 2/);
768
+ assert.match(firstReminder.text, /build it/, "the reminder carries the task text");
769
+ assert.match(
770
+ firstReminder.text,
771
+ /\[onlyne\] task 11111111-1111-4111-8111-111111111111 from role:planner \(kind task\)/,
772
+ "and the identity and source the injection named",
773
+ );
774
+ assert.match(firstReminder.text, /\[onlyne\] attachment saved to: .*shot\.png/, "the path travels as text");
775
+ assert.deepEqual(firstReminder.parts, [], "and the image itself is not handed over twice");
776
+ assert.doesNotMatch(firstReminder.text, /Read the incoming task/, "the role prose is already in the context");
777
+
778
+ agent.onTurnStart();
779
+ agent.onTurnEnd();
780
+ agent.onSettled();
781
+ const secondRung = await waitFor(() =>
782
+ surface.calls.wakeUser.length === 3 ? surface.calls.wakeUser : null,
783
+ );
784
+ assert.match(secondRung.at(-1).text, /reminder 2 of 2/, "the reminder-woken turn keeps the episode's count");
785
+ assert.deepEqual(host.of("report").filter((report) => report.kind === "complete"), []);
786
+
787
+ // The next turn is the one the second reminder wakes, so it does not reset
788
+ // the episode. Let that turn end without a settle, then start a turn of the
789
+ // session's own: that ordinary turn begins a fresh episode.
790
+ agent.onTurnStart();
791
+ agent.onTurnEnd();
792
+ agent.onTurnStart();
793
+ agent.onTurnEnd();
794
+ agent.onSettled();
795
+ const reset = await waitFor(() =>
796
+ surface.calls.wakeUser.length === 4 ? surface.calls.wakeUser : null,
797
+ );
798
+ assert.match(reset.at(-1).text, /reminder 1 of 2/, "an ordinary turn clears the episode's rung count");
799
+
800
+ agent.onTurnStart();
801
+ agent.onTurnEnd();
802
+ agent.onSettled();
803
+ const secondFresh = await waitFor(() =>
804
+ surface.calls.wakeUser.length === 5 ? surface.calls.wakeUser : null,
805
+ );
806
+ assert.match(secondFresh.at(-1).text, /reminder 2 of 2/);
712
807
 
713
- // The third idle: the bound is spent, so the open task fails and the session
714
- // leaves — the tool was never called, and no completion was ever reported.
808
+ // The third idle in the fresh episode spends the bound, so the open task
809
+ // fails and the session leaves — the tool was never called.
715
810
  agent.onTurnStart();
716
811
  agent.onTurnEnd();
717
812
  agent.onSettled();
@@ -722,22 +817,22 @@ test("an idle without a completion is reminded, the idle past the bound fails th
722
817
  head: "no completion after 2 idle reminders",
723
818
  });
724
819
  assert.deepEqual(surface.calls.exits, ["failed"], "the ladder's failure leaves the session");
725
- assert.equal(surface.calls.wakeUser.length, 3, "the bound is spent: no third reminder");
820
+ assert.equal(surface.calls.wakeUser.length, 5, "the bound is spent: no third reminder");
726
821
 
727
822
  // A turn that hands the outcome over is never reminded: the tool call is the
728
823
  // exit the ladder exists to get.
729
- const second = await startAgent();
730
- second.agent.start();
731
- await waitFor(() => second.host.of("report").length >= 1);
732
- second.host.notify("assign", assignArgs());
733
- await waitFor(() => (second.surface.calls.wakeUser.length === 1 ? true : null));
734
- second.agent.onTurnStart();
735
- second.agent.onTurnEnd();
736
- await second.agent.completeFromTool({ outcome: "done", text: "built it" });
737
- second.agent.onSettled();
824
+ const completed = await startAgent();
825
+ completed.agent.start();
826
+ await waitFor(() => completed.host.of("report").length >= 1);
827
+ completed.host.notify("assign", assignArgs());
828
+ await waitFor(() => (completed.surface.calls.wakeUser.length === 1 ? true : null));
829
+ completed.agent.onTurnStart();
830
+ completed.agent.onTurnEnd();
831
+ await completed.agent.completeFromTool({ outcome: "done", text: "built it" });
832
+ completed.agent.onSettled();
738
833
  await new Promise((resolve) => setTimeout(resolve, 60));
739
- assert.equal(second.surface.calls.wakeUser.length, 1, "the completion is the exit: no reminder follows it");
740
- assert.deepEqual(second.surface.calls.exits, ["done"]);
834
+ assert.equal(completed.surface.calls.wakeUser.length, 1, "the completion is the exit: no reminder follows it");
835
+ assert.deepEqual(completed.surface.calls.exits, ["done"]);
741
836
  });
742
837
 
743
838
  test("an assigned task that never ran is not completed", async () => {
@@ -873,12 +968,7 @@ test("the exit waits for the client's acknowledgement of the completion report",
873
968
  assert.deepEqual(surface.calls.exits, ["done"]);
874
969
  });
875
970
 
876
- // A session that only ever reported `running` and then completed leaves the
877
- // ledger saying `running` forever: the client settles the row from the
878
- // completion report and hears nothing more. One final observation, sent after
879
- // the completion is acknowledged and before the process leaves, is what makes
880
- // an exited session read idle.
881
- test("a completion after a running beat publishes the settled observation before the exit", async () => {
971
+ test("the completion report is the last frame the host receives before the exit", async () => {
882
972
  const { agent, host, surface } = await startAgent();
883
973
  agent.start();
884
974
  await waitFor(() => host.of("report").length >= 1);
@@ -892,20 +982,14 @@ test("a completion after a running beat publishes the settled observation before
892
982
 
893
983
  await agent.complete(TASK_ID, "done", "OK");
894
984
  const reports = host.of("report").filter((report) => report.kind !== "ready");
895
- const kinds = reports.map((report) => report.kind);
896
- assert.deepEqual(
897
- kinds.slice(-2),
898
- ["complete", "heartbeat"],
899
- "the settled observation follows the completion: " + JSON.stringify(kinds),
900
- );
901
- const settled = reports.at(-1).data.observed;
902
- assert.equal(settled.agent, "idle");
903
- assert.deepEqual(dimensionKeys(settled), OBSERVED_KEYS, "the last observation states the agent and nothing else");
985
+ assert.equal(reports.at(-1).kind, "complete", "a running beat is not followed by a settled observation");
904
986
  assert.deepEqual(surface.calls.exits, ["done"]);
905
987
  });
906
988
 
907
- test("a session whose last beat was idle publishes no extra observation", async () => {
908
- const { agent, host, surface } = await startAgent();
989
+ test("a reconnect beat re-derives the phase from the surface", async () => {
990
+ let waiting = true;
991
+ const surface = fakeSurface({ waiting: () => waiting });
992
+ const { agent, host } = await startAgent({ surface });
909
993
  agent.start();
910
994
  await waitFor(() => host.of("report").length >= 1);
911
995
  host.notify("assign", assignArgs());
@@ -913,45 +997,21 @@ test("a session whose last beat was idle publishes no extra observation", async
913
997
  agent.onTurnStart();
914
998
  agent.onTurnEnd();
915
999
  await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "idle"));
916
- agent.noteAssistantText("OK");
917
- const before = host.of("report").filter((report) => report.kind === "heartbeat").length;
918
-
919
- await agent.complete(TASK_ID, "done", "OK");
920
- assert.equal(
921
- host.of("report").filter((report) => report.kind === "heartbeat").length,
922
- before,
923
- "an already-idle session needs no second idle observation",
924
- );
925
- assert.deepEqual(surface.calls.exits, ["done"]);
926
- });
927
-
928
- test("a queued completion and its settled observation flush in order after reconnect", async () => {
929
- const { agent, host, surface } = await startAgent();
930
- agent.start();
931
- await waitFor(() => host.of("report").length >= 1);
932
- host.notify("assign", assignArgs());
933
- await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
934
- agent.onTurnStart();
935
- agent.onTurnEnd();
936
- agent.noteAssistantText("done offline");
937
1000
 
1001
+ const beforeReconnect = host.frames.length;
1002
+ waiting = false;
938
1003
  for (const socket of [...host.sockets]) socket.destroy();
939
1004
  await waitFor(() => (agent.status().connected === false ? true : null));
940
- await agent.completeFromTool({ outcome: "done" });
941
- assert.deepEqual(surface.calls.exits, [], "nothing exits before the report lands");
942
-
943
- await waitFor(() => (host.connections >= 2 ? true : null));
944
- await waitFor(() => (surface.calls.exits.length === 1 ? true : null));
945
- const reports = host.of("report").filter((report) => report.kind !== "ready");
946
- assert.deepEqual(
947
- reports.slice(-2).map((report) => [report.kind, report.data.observed?.agent ?? null]),
948
- [
949
- ["complete", null],
950
- ["heartbeat", "idle"],
951
- ],
952
- "the settled observation rides after the flushed completion: " + JSON.stringify(reports),
1005
+ const beat = await waitFor(() => host.frames
1006
+ .slice(beforeReconnect)
1007
+ .map((entry) => entry.frame)
1008
+ .find((frame) => frame.op === "report" && frame.args?.kind === "heartbeat"));
1009
+ assert.equal(beat.args.data.observed.agent, "running", "the reconnect asks the surface again");
1010
+ assert.equal(
1011
+ heartbeats(host).at(-1).agent,
1012
+ "running",
1013
+ "the old idle beat does not survive the reconnect",
953
1014
  );
954
- assert.deepEqual(dimensionKeys(reports.at(-1).data.observed), OBSERVED_KEYS, "the flushed tail states the agent and nothing else");
955
1015
  });
956
1016
 
957
1017
  test("an inbound image is written under the workspace and handed to pi", async () => {
@@ -986,7 +1046,7 @@ test("a probe is answered with a heartbeat for the live task", async () => {
986
1046
  const heartbeat = await waitFor(() =>
987
1047
  host.of("report").find((report) => report.kind === "heartbeat" && report.data.task_id === TASK_ID),
988
1048
  );
989
- assert.equal(heartbeat.data.observed.agent, "running");
1049
+ assert.equal(heartbeat.data.observed.agent, "idle", "the probe re-derives a waiting session");
990
1050
  assert.ok(heartbeat.data.seq > SEQ_BASE, "each heartbeat advances the plugin's own sequence");
991
1051
  });
992
1052