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/README.md +324 -22
- package/README.zh.md +35 -16
- package/package.json +1 -1
- package/src/activity.test.mjs +0 -6
- package/src/agent.live.test.mjs +1 -0
- package/src/agent.mjs +73 -63
- package/src/agent.test.mjs +139 -173
- package/src/background-work.mjs +176 -0
- package/src/background-work.test.mjs +128 -0
- package/src/config.test.mjs +0 -8
- package/src/index.ts +9 -2
- package/src/pi-surface.mjs +61 -5
- package/src/pi-surface.test.mjs +160 -0
- package/src/protocol.test.mjs +0 -10
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
|
|
329
|
-
|
|
330
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
708
|
-
|
|
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
|
-
/**
|
|
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(
|
|
758
|
+
void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
|
|
718
759
|
this.armSettleFallback();
|
|
719
760
|
}
|
|
720
761
|
|
|
721
|
-
/**
|
|
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.
|
|
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,
|
|
730
|
-
*
|
|
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.
|
|
782
|
+
if (await this.derivedPhase() !== "idle") {
|
|
737
783
|
this.armSettleFallback();
|
|
738
784
|
return;
|
|
739
785
|
}
|
|
740
|
-
|
|
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) {
|