pi-onlyne 1.2.0 → 1.2.2

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) {