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/README.md +323 -21
- package/README.zh.md +35 -16
- package/package.json +1 -1
- package/src/agent.live.test.mjs +1 -0
- package/src/agent.mjs +73 -63
- package/src/agent.test.mjs +161 -101
- package/src/background-work.mjs +176 -0
- package/src/background-work.test.mjs +128 -0
- package/src/index.ts +9 -2
- package/src/pi-surface.mjs +40 -0
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) {
|
package/src/agent.test.mjs
CHANGED
|
@@ -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, "
|
|
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, "
|
|
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("
|
|
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
|
|
644
|
-
let
|
|
645
|
-
const surface = fakeSurface({
|
|
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
|
-
|
|
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("
|
|
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
|
-
//
|
|
687
|
-
//
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
|
714
|
-
// leaves — the tool was never called
|
|
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,
|
|
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
|
|
730
|
-
|
|
731
|
-
await waitFor(() =>
|
|
732
|
-
|
|
733
|
-
await waitFor(() => (
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
await
|
|
737
|
-
|
|
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(
|
|
740
|
-
assert.deepEqual(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
908
|
-
|
|
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
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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, "
|
|
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
|
|