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.test.mjs
CHANGED
|
@@ -11,7 +11,6 @@ import { fileURLToPath } from "node:url";
|
|
|
11
11
|
import { afterEach, test } from "node:test";
|
|
12
12
|
|
|
13
13
|
import { OnlyneAgent } from "./agent.mjs";
|
|
14
|
-
import { MAX_LINES, MAX_WIDTH } from "./activity.mjs";
|
|
15
14
|
import { DEFAULT_IDLE_REMINDERS } from "./config.mjs";
|
|
16
15
|
import { createFrameDecoder, encodeFrame } from "./frame.mjs";
|
|
17
16
|
import { SEQ_BASE, readyReport } from "./protocol.mjs";
|
|
@@ -206,6 +205,7 @@ class FakeHost {
|
|
|
206
205
|
/** The effect surface the agent drives, recorded for assertions. */
|
|
207
206
|
function fakeSurface(options = {}) {
|
|
208
207
|
const calls = { wakeUser: [], prose: [], entries: [], status: [], exits: [], widget: [] };
|
|
208
|
+
const flag = (value, fallback) => (value === undefined ? fallback : typeof value === "function" ? value() : value);
|
|
209
209
|
return {
|
|
210
210
|
calls,
|
|
211
211
|
available: {
|
|
@@ -237,6 +237,9 @@ function fakeSurface(options = {}) {
|
|
|
237
237
|
widget: (lines) => calls.widget.push(lines),
|
|
238
238
|
welcome: () => {},
|
|
239
239
|
isIdle: () => (options.idle === undefined ? true : options.idle()),
|
|
240
|
+
async waitingForInput() {
|
|
241
|
+
return Boolean(flag(options.waiting, true) && !flag(options.backgroundWork, false));
|
|
242
|
+
},
|
|
240
243
|
exit: (reason) => calls.exits.push(reason),
|
|
241
244
|
};
|
|
242
245
|
}
|
|
@@ -345,7 +348,7 @@ test("every heartbeat names the Orca pane this process was spawned in", async ()
|
|
|
345
348
|
orca: { pane_key: PANE_KEY, tab_id: PANE_TAB, leaf_id: PANE_LEAF, handle: "term_1" },
|
|
346
349
|
});
|
|
347
350
|
// The binding rides beside the state dimensions instead of replacing them.
|
|
348
|
-
assert.equal(observed.agent, "
|
|
351
|
+
assert.equal(observed.agent, "idle", "the default fake surface is waiting for input");
|
|
349
352
|
assert.equal(observed.resource, "attached");
|
|
350
353
|
assert.deepEqual(Object.keys(observed), [...OBSERVED_KEYS, "host"]);
|
|
351
354
|
assert.equal(observed.version.generation, 1);
|
|
@@ -370,24 +373,6 @@ test("a reconnect reports the same pane", async () => {
|
|
|
370
373
|
});
|
|
371
374
|
});
|
|
372
375
|
|
|
373
|
-
test("a second task in the same pane reports the same pane", async () => {
|
|
374
|
-
await inOrcaPane(PANE_ENV, async () => {
|
|
375
|
-
const { host } = await mountedAgent();
|
|
376
|
-
host.notify("assign", assignArgs());
|
|
377
|
-
await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
|
|
378
|
-
host.notify("probe", {});
|
|
379
|
-
await waitFor(() => (heartbeats(host).length ? true : null));
|
|
380
|
-
|
|
381
|
-
// The binding belongs to the process, not to the task: handing this pane
|
|
382
|
-
// another task cannot change where the process runs.
|
|
383
|
-
host.notify("assign", { ...assignArgs(), task_id: "22222222-2222-4222-8222-222222222222" });
|
|
384
|
-
await waitFor(() => (host.of("assign_ack").length === 2 ? true : null));
|
|
385
|
-
host.notify("probe", {});
|
|
386
|
-
const reports = await waitFor(() => (heartbeats(host).length >= 2 ? heartbeats(host) : null));
|
|
387
|
-
assert.deepEqual(reports.at(-1).host, reports[0].host);
|
|
388
|
-
});
|
|
389
|
-
});
|
|
390
|
-
|
|
391
376
|
test("a pi outside an Orca pane reports no host at all", async () => {
|
|
392
377
|
await inOrcaPane({}, async () => {
|
|
393
378
|
const { host } = await mountedAgent();
|
|
@@ -397,23 +382,7 @@ test("a pi outside an Orca pane reports no host at all", async () => {
|
|
|
397
382
|
|
|
398
383
|
const [observed] = await waitFor(() => (heartbeats(host).length ? heartbeats(host) : null));
|
|
399
384
|
assert.equal("host" in observed, false, "no pane, no binding");
|
|
400
|
-
assert.equal(observed.agent, "
|
|
401
|
-
});
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
test("a pane that exports no handle reports the pane without inventing one", async () => {
|
|
405
|
-
await inOrcaPane({ ORCA_PANE_KEY: PANE_KEY }, async () => {
|
|
406
|
-
const { host } = await mountedAgent();
|
|
407
|
-
host.notify("assign", assignArgs());
|
|
408
|
-
await waitFor(() => (host.of("assign_ack").length === 1 ? true : null));
|
|
409
|
-
host.notify("probe", {});
|
|
410
|
-
|
|
411
|
-
const [observed] = await waitFor(() => (heartbeats(host).length ? heartbeats(host) : null));
|
|
412
|
-
// The pane key carries both ids; the handle is a field the environment
|
|
413
|
-
// either has or has not, and a missing one is absent rather than empty.
|
|
414
|
-
assert.deepEqual(observed.host, {
|
|
415
|
-
orca: { pane_key: PANE_KEY, tab_id: PANE_TAB, leaf_id: PANE_LEAF },
|
|
416
|
-
});
|
|
385
|
+
assert.equal(observed.agent, "idle", "the tuple is still a full observation");
|
|
417
386
|
});
|
|
418
387
|
});
|
|
419
388
|
|
|
@@ -584,7 +553,7 @@ test("an assign sharing the hello reply's chunk waits for the welcome", async ()
|
|
|
584
553
|
assert.deepEqual(acks[0], { task_id: TASK_ID, accepted: true });
|
|
585
554
|
});
|
|
586
555
|
|
|
587
|
-
test("
|
|
556
|
+
test("a waiting turn end reports idle, and the settle after it re-sends the assignment", async () => {
|
|
588
557
|
const { agent, host, surface } = await startAgent();
|
|
589
558
|
agent.start();
|
|
590
559
|
await waitFor(() => host.of("report").length >= 1);
|
|
@@ -616,6 +585,29 @@ test("turn hooks report running then idle, and the settle after them re-sends th
|
|
|
616
585
|
assert.deepEqual(surface.calls.exits, [], "a task with a rung left is not failed");
|
|
617
586
|
});
|
|
618
587
|
|
|
588
|
+
test("a busy turn end reports no idle beat and does not settle the ladder", async () => {
|
|
589
|
+
const surface = fakeSurface({ waiting: false });
|
|
590
|
+
const { agent, host } = await startAgent({ surface });
|
|
591
|
+
agent.start();
|
|
592
|
+
await waitFor(() => host.of("report").length >= 1);
|
|
593
|
+
host.notify("assign", assignArgs());
|
|
594
|
+
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
595
|
+
|
|
596
|
+
agent.onTurnStart();
|
|
597
|
+
await waitFor(() => host.of("report").find((report) => report.data?.observed?.agent === "running"));
|
|
598
|
+
agent.onTurnEnd();
|
|
599
|
+
agent.onSettled();
|
|
600
|
+
await waitFor(() => heartbeats(host).length >= 3);
|
|
601
|
+
|
|
602
|
+
assert.equal(
|
|
603
|
+
heartbeats(host).some((observed) => observed.agent === "idle"),
|
|
604
|
+
false,
|
|
605
|
+
"the turn-end beat follows the busy surface rather than assuming the hook means idle",
|
|
606
|
+
);
|
|
607
|
+
assert.equal(surface.calls.wakeUser.length, 1, "the ladder waits while the turn is still running");
|
|
608
|
+
assert.deepEqual(surface.calls.exits, []);
|
|
609
|
+
});
|
|
610
|
+
|
|
619
611
|
// The live case found this ordering too: pi's turn-end hook fires in the same
|
|
620
612
|
// millisecond as the settle that reports the completion, so the turn-end
|
|
621
613
|
// heartbeat lands after the completion report. The beat no longer carries a
|
|
@@ -640,9 +632,9 @@ test("a turn-end heartbeat after the completion never leaves the plugin", async
|
|
|
640
632
|
assert.deepEqual(host.of("report").slice(reports), [], "the completion is the last report");
|
|
641
633
|
});
|
|
642
634
|
|
|
643
|
-
test("a busy pi holds the settle decision until it is
|
|
644
|
-
let
|
|
645
|
-
const surface = fakeSurface({
|
|
635
|
+
test("a busy pi holds the settle decision until it is waiting for input", async () => {
|
|
636
|
+
let waiting = false;
|
|
637
|
+
const surface = fakeSurface({ waiting: () => waiting });
|
|
646
638
|
const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
|
|
647
639
|
agent.start();
|
|
648
640
|
await waitFor(() => host.of("report").length >= 1);
|
|
@@ -653,20 +645,44 @@ test("a busy pi holds the settle decision until it is idle", async () => {
|
|
|
653
645
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
654
646
|
assert.equal(surface.calls.wakeUser.length, 1, "a busy pi is not reminded");
|
|
655
647
|
|
|
656
|
-
|
|
648
|
+
waiting = true;
|
|
657
649
|
const reminded = await waitFor(() =>
|
|
658
650
|
surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
|
|
659
651
|
);
|
|
660
652
|
assert.match(reminded[1].text, /build it/, "the decision waits for the idle it is about");
|
|
661
653
|
});
|
|
662
654
|
|
|
655
|
+
test("a live background task holds the ladder off until its task is terminal", async () => {
|
|
656
|
+
let backgroundWork = true;
|
|
657
|
+
const surface = fakeSurface({ backgroundWork: () => backgroundWork });
|
|
658
|
+
const { agent, host } = await startAgent({ surface, options: { settleFallbackMs: 40 } });
|
|
659
|
+
agent.start();
|
|
660
|
+
await waitFor(() => host.of("report").length >= 1);
|
|
661
|
+
host.notify("assign", assignArgs());
|
|
662
|
+
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
663
|
+
|
|
664
|
+
agent.onTurnStart();
|
|
665
|
+
agent.onTurnEnd();
|
|
666
|
+
agent.onSettled();
|
|
667
|
+
await waitFor(() => heartbeats(host).at(-1)?.agent === "running");
|
|
668
|
+
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
669
|
+
assert.equal(surface.calls.wakeUser.length, 1, "work off the agent loop keeps the ladder off");
|
|
670
|
+
|
|
671
|
+
backgroundWork = false;
|
|
672
|
+
agent.onSettled();
|
|
673
|
+
const reminded = await waitFor(() =>
|
|
674
|
+
surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
|
|
675
|
+
);
|
|
676
|
+
assert.match(reminded[1].text, /build it/, "the terminal task releases the idle ladder");
|
|
677
|
+
});
|
|
678
|
+
|
|
663
679
|
// The idle ladder (`docs/SWARM-REFACTOR-GRILLME.md` §4.2): a turn that ends
|
|
664
680
|
// without a completion exit re-sends the assignment, and the idle that finds the
|
|
665
681
|
// bound spent fails the task instead of settling it `done`. The bound is the
|
|
666
682
|
// workspace's (`config.mjs`, two by default) and the count lives on the record
|
|
667
683
|
// `onAssign` wrote for the task, which is why a task can be reminded twice and
|
|
668
684
|
// failed on the third idle without the tool ever being called.
|
|
669
|
-
test("
|
|
685
|
+
test("only settled idles spend ladder rungs, ordinary turns reset them, and the bound fails the task", async () => {
|
|
670
686
|
const { agent, host, surface } = await startAgent();
|
|
671
687
|
assert.equal(agent.idleReminders, DEFAULT_IDLE_REMINDERS, "the bound is the workspace's, two by default");
|
|
672
688
|
agent.start();
|
|
@@ -683,35 +699,59 @@ test("an idle without a completion is reminded, the idle past the bound fails th
|
|
|
683
699
|
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
684
700
|
assert.equal(surface.calls.wakeUser[0].parts.length, 1, "the injection carries the image");
|
|
685
701
|
|
|
686
|
-
//
|
|
687
|
-
//
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
}
|
|
702
|
+
// The first turn belongs to the session itself. Its settled idle spends rung
|
|
703
|
+
// one. The turn that reminder wakes does not reset the episode, so its own
|
|
704
|
+
// settled idle spends rung two rather than starting over.
|
|
705
|
+
agent.onTurnStart();
|
|
706
|
+
agent.onTurnEnd();
|
|
707
|
+
agent.onSettled();
|
|
708
|
+
const first = await waitFor(() =>
|
|
709
|
+
surface.calls.wakeUser.length === 2 ? surface.calls.wakeUser : null,
|
|
710
|
+
);
|
|
711
|
+
const firstReminder = first.at(-1);
|
|
712
|
+
assert.match(firstReminder.text, /reminder 1 of 2/);
|
|
713
|
+
assert.match(firstReminder.text, /build it/, "the reminder carries the task text");
|
|
714
|
+
assert.match(
|
|
715
|
+
firstReminder.text,
|
|
716
|
+
/\[onlyne\] task 11111111-1111-4111-8111-111111111111 from role:planner \(kind task\)/,
|
|
717
|
+
"and the identity and source the injection named",
|
|
718
|
+
);
|
|
719
|
+
assert.match(firstReminder.text, /\[onlyne\] attachment saved to: .*shot\.png/, "the path travels as text");
|
|
720
|
+
assert.deepEqual(firstReminder.parts, [], "and the image itself is not handed over twice");
|
|
721
|
+
assert.doesNotMatch(firstReminder.text, /Read the incoming task/, "the role prose is already in the context");
|
|
712
722
|
|
|
713
|
-
|
|
714
|
-
|
|
723
|
+
agent.onTurnStart();
|
|
724
|
+
agent.onTurnEnd();
|
|
725
|
+
agent.onSettled();
|
|
726
|
+
const secondRung = await waitFor(() =>
|
|
727
|
+
surface.calls.wakeUser.length === 3 ? surface.calls.wakeUser : null,
|
|
728
|
+
);
|
|
729
|
+
assert.match(secondRung.at(-1).text, /reminder 2 of 2/, "the reminder-woken turn keeps the episode's count");
|
|
730
|
+
assert.deepEqual(host.of("report").filter((report) => report.kind === "complete"), []);
|
|
731
|
+
|
|
732
|
+
// The next turn is the one the second reminder wakes, so it does not reset
|
|
733
|
+
// the episode. Let that turn end without a settle, then start a turn of the
|
|
734
|
+
// session's own: that ordinary turn begins a fresh episode.
|
|
735
|
+
agent.onTurnStart();
|
|
736
|
+
agent.onTurnEnd();
|
|
737
|
+
agent.onTurnStart();
|
|
738
|
+
agent.onTurnEnd();
|
|
739
|
+
agent.onSettled();
|
|
740
|
+
const reset = await waitFor(() =>
|
|
741
|
+
surface.calls.wakeUser.length === 4 ? surface.calls.wakeUser : null,
|
|
742
|
+
);
|
|
743
|
+
assert.match(reset.at(-1).text, /reminder 1 of 2/, "an ordinary turn clears the episode's rung count");
|
|
744
|
+
|
|
745
|
+
agent.onTurnStart();
|
|
746
|
+
agent.onTurnEnd();
|
|
747
|
+
agent.onSettled();
|
|
748
|
+
const secondFresh = await waitFor(() =>
|
|
749
|
+
surface.calls.wakeUser.length === 5 ? surface.calls.wakeUser : null,
|
|
750
|
+
);
|
|
751
|
+
assert.match(secondFresh.at(-1).text, /reminder 2 of 2/);
|
|
752
|
+
|
|
753
|
+
// The third idle in the fresh episode spends the bound, so the open task
|
|
754
|
+
// fails and the session leaves — the tool was never called.
|
|
715
755
|
agent.onTurnStart();
|
|
716
756
|
agent.onTurnEnd();
|
|
717
757
|
agent.onSettled();
|
|
@@ -722,22 +762,22 @@ test("an idle without a completion is reminded, the idle past the bound fails th
|
|
|
722
762
|
head: "no completion after 2 idle reminders",
|
|
723
763
|
});
|
|
724
764
|
assert.deepEqual(surface.calls.exits, ["failed"], "the ladder's failure leaves the session");
|
|
725
|
-
assert.equal(surface.calls.wakeUser.length,
|
|
765
|
+
assert.equal(surface.calls.wakeUser.length, 5, "the bound is spent: no third reminder");
|
|
726
766
|
|
|
727
767
|
// A turn that hands the outcome over is never reminded: the tool call is the
|
|
728
768
|
// exit the ladder exists to get.
|
|
729
|
-
const
|
|
730
|
-
|
|
731
|
-
await waitFor(() =>
|
|
732
|
-
|
|
733
|
-
await waitFor(() => (
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
await
|
|
737
|
-
|
|
769
|
+
const completed = await startAgent();
|
|
770
|
+
completed.agent.start();
|
|
771
|
+
await waitFor(() => completed.host.of("report").length >= 1);
|
|
772
|
+
completed.host.notify("assign", assignArgs());
|
|
773
|
+
await waitFor(() => (completed.surface.calls.wakeUser.length === 1 ? true : null));
|
|
774
|
+
completed.agent.onTurnStart();
|
|
775
|
+
completed.agent.onTurnEnd();
|
|
776
|
+
await completed.agent.completeFromTool({ outcome: "done", text: "built it" });
|
|
777
|
+
completed.agent.onSettled();
|
|
738
778
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
739
|
-
assert.equal(
|
|
740
|
-
assert.deepEqual(
|
|
779
|
+
assert.equal(completed.surface.calls.wakeUser.length, 1, "the completion is the exit: no reminder follows it");
|
|
780
|
+
assert.deepEqual(completed.surface.calls.exits, ["done"]);
|
|
741
781
|
});
|
|
742
782
|
|
|
743
783
|
test("an assigned task that never ran is not completed", async () => {
|
|
@@ -873,39 +913,10 @@ test("the exit waits for the client's acknowledgement of the completion report",
|
|
|
873
913
|
assert.deepEqual(surface.calls.exits, ["done"]);
|
|
874
914
|
});
|
|
875
915
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
// an exited session read idle.
|
|
881
|
-
test("a completion after a running beat publishes the settled observation before the exit", async () => {
|
|
882
|
-
const { agent, host, surface } = await startAgent();
|
|
883
|
-
agent.start();
|
|
884
|
-
await waitFor(() => host.of("report").length >= 1);
|
|
885
|
-
host.notify("assign", assignArgs());
|
|
886
|
-
await waitFor(() => (surface.calls.wakeUser.length === 1 ? true : null));
|
|
887
|
-
agent.onTurnStart();
|
|
888
|
-
await waitFor(() =>
|
|
889
|
-
host.of("report").find((report) => report.data?.observed?.agent === "running"),
|
|
890
|
-
);
|
|
891
|
-
agent.noteAssistantText("OK");
|
|
892
|
-
|
|
893
|
-
await agent.complete(TASK_ID, "done", "OK");
|
|
894
|
-
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");
|
|
904
|
-
assert.deepEqual(surface.calls.exits, ["done"]);
|
|
905
|
-
});
|
|
906
|
-
|
|
907
|
-
test("a session whose last beat was idle publishes no extra observation", async () => {
|
|
908
|
-
const { agent, host, surface } = await startAgent();
|
|
916
|
+
test("a reconnect beat re-derives the phase from the surface", async () => {
|
|
917
|
+
let waiting = true;
|
|
918
|
+
const surface = fakeSurface({ waiting: () => waiting });
|
|
919
|
+
const { agent, host } = await startAgent({ surface });
|
|
909
920
|
agent.start();
|
|
910
921
|
await waitFor(() => host.of("report").length >= 1);
|
|
911
922
|
host.notify("assign", assignArgs());
|
|
@@ -913,45 +924,21 @@ test("a session whose last beat was idle publishes no extra observation", async
|
|
|
913
924
|
agent.onTurnStart();
|
|
914
925
|
agent.onTurnEnd();
|
|
915
926
|
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
927
|
|
|
928
|
+
const beforeReconnect = host.frames.length;
|
|
929
|
+
waiting = false;
|
|
938
930
|
for (const socket of [...host.sockets]) socket.destroy();
|
|
939
931
|
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),
|
|
932
|
+
const beat = await waitFor(() => host.frames
|
|
933
|
+
.slice(beforeReconnect)
|
|
934
|
+
.map((entry) => entry.frame)
|
|
935
|
+
.find((frame) => frame.op === "report" && frame.args?.kind === "heartbeat"));
|
|
936
|
+
assert.equal(beat.args.data.observed.agent, "running", "the reconnect asks the surface again");
|
|
937
|
+
assert.equal(
|
|
938
|
+
heartbeats(host).at(-1).agent,
|
|
939
|
+
"running",
|
|
940
|
+
"the old idle beat does not survive the reconnect",
|
|
953
941
|
);
|
|
954
|
-
assert.deepEqual(dimensionKeys(reports.at(-1).data.observed), OBSERVED_KEYS, "the flushed tail states the agent and nothing else");
|
|
955
942
|
});
|
|
956
943
|
|
|
957
944
|
test("an inbound image is written under the workspace and handed to pi", async () => {
|
|
@@ -986,7 +973,7 @@ test("a probe is answered with a heartbeat for the live task", async () => {
|
|
|
986
973
|
const heartbeat = await waitFor(() =>
|
|
987
974
|
host.of("report").find((report) => report.kind === "heartbeat" && report.data.task_id === TASK_ID),
|
|
988
975
|
);
|
|
989
|
-
assert.equal(heartbeat.data.observed.agent, "
|
|
976
|
+
assert.equal(heartbeat.data.observed.agent, "idle", "the probe re-derives a waiting session");
|
|
990
977
|
assert.ok(heartbeat.data.seq > SEQ_BASE, "each heartbeat advances the plugin's own sequence");
|
|
991
978
|
});
|
|
992
979
|
|
|
@@ -1382,27 +1369,6 @@ test("a repeating fault folds into one panel line with a count", () => {
|
|
|
1382
1369
|
assert.equal(surface.calls.widget.length, 3, "every event redraws the same panel");
|
|
1383
1370
|
});
|
|
1384
1371
|
|
|
1385
|
-
test("the panel stays inside its line and width budget", () => {
|
|
1386
|
-
const surface = fakeSurface({ widget: true });
|
|
1387
|
-
const agent = new OnlyneAgent({
|
|
1388
|
-
socketPath: join(tmpdir(), "pi-onlyne-unused-socket"),
|
|
1389
|
-
cwd: tmpdir(),
|
|
1390
|
-
role: "planner",
|
|
1391
|
-
sessionId: SESSION_ID,
|
|
1392
|
-
taskId: TASK_ID,
|
|
1393
|
-
surface,
|
|
1394
|
-
log: () => {},
|
|
1395
|
-
});
|
|
1396
|
-
for (let index = 0; index < 40; index += 1) {
|
|
1397
|
-
agent.notice("in", `task ${index} from gateway:wechat:room ${"长".repeat(400)}`);
|
|
1398
|
-
}
|
|
1399
|
-
const [render] = surface.calls.widget.slice(-1);
|
|
1400
|
-
assert.ok(render.length <= MAX_LINES, `panel grew to ${render.length} lines`);
|
|
1401
|
-
for (const line of render) {
|
|
1402
|
-
assert.ok(Array.from(line).length <= MAX_WIDTH, `line overruns its width: ${line}`);
|
|
1403
|
-
}
|
|
1404
|
-
});
|
|
1405
|
-
|
|
1406
1372
|
test("a host without the widget keeps the footer line and the stderr line", async () => {
|
|
1407
1373
|
const surface = fakeSurface();
|
|
1408
1374
|
const { agent, host, logs } = await startAgent({ surface });
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// The one question a background-task extension makes necessary: is this
|
|
2
|
+
// session's work still running somewhere the agent loop cannot see?
|
|
3
|
+
//
|
|
4
|
+
// `pi-background-tasks` and its relatives take a long command off the loop — the
|
|
5
|
+
// tool call returns a task id at once and the child process carries on. pi then
|
|
6
|
+
// waits for input while the work runs, so `ctx.isIdle()` alone would report a
|
|
7
|
+
// session as idle with a task in flight. This probe reads the extension's own
|
|
8
|
+
// live task list over the pi EventBus, and only when the extension is installed:
|
|
9
|
+
// without one of its tools there is nothing to recognise, nothing to query, and
|
|
10
|
+
// nothing to wait for.
|
|
11
|
+
//
|
|
12
|
+
// The contract is that package's documented `eventbus-v1` surface
|
|
13
|
+
// (`pi-background-tasks/docs/api/eventbus-v1.md`): one request frame in, one
|
|
14
|
+
// response frame out, both closed objects carrying a schema id. A response that
|
|
15
|
+
// never arrives, a frame that does not parse, an error response, and a host with
|
|
16
|
+
// no EventBus all read as "no background work known" and leave the plugin's own
|
|
17
|
+
// judgement untouched.
|
|
18
|
+
|
|
19
|
+
/** The tools that mark the extension as installed. */
|
|
20
|
+
export const BACKGROUND_TOOL_NAMES = Object.freeze([
|
|
21
|
+
"bg_run",
|
|
22
|
+
"bg_run_pi_attested",
|
|
23
|
+
"bg_status",
|
|
24
|
+
"bg_logs",
|
|
25
|
+
"bg_kill",
|
|
26
|
+
"bg_delegate",
|
|
27
|
+
"bg_result",
|
|
28
|
+
"fusion_reason",
|
|
29
|
+
"fusion_investigate",
|
|
30
|
+
"fusion_research",
|
|
31
|
+
"fusion_validate",
|
|
32
|
+
"fusion_web_fetch",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const REQUEST_CHANNEL = "pi-background-tasks:request:v1";
|
|
36
|
+
const RESPONSE_CHANNEL = "pi-background-tasks:response:v1";
|
|
37
|
+
const REQUEST_SCHEMA = "pi-background-tasks.extension-request.v1";
|
|
38
|
+
const RESPONSE_SCHEMA = "pi-background-tasks.extension-response.v1";
|
|
39
|
+
|
|
40
|
+
/** Task statuses that mean the work is still going. */
|
|
41
|
+
const LIVE_TASK_STATUS = "running";
|
|
42
|
+
|
|
43
|
+
/** How long one status query waits for its answer. */
|
|
44
|
+
export const DEFAULT_STATUS_TIMEOUT_MS = 500;
|
|
45
|
+
|
|
46
|
+
/** @param {string} name */
|
|
47
|
+
export function isBackgroundTool(name) {
|
|
48
|
+
return BACKGROUND_TOOL_NAMES.includes(name);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let requestCounter = 0;
|
|
52
|
+
|
|
53
|
+
function nextRequestId() {
|
|
54
|
+
requestCounter += 1;
|
|
55
|
+
return `onlyne-bg-status-${requestCounter}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isResponseFor(frame, requestId) {
|
|
59
|
+
return frame
|
|
60
|
+
&& typeof frame === "object"
|
|
61
|
+
&& frame.schema_version === RESPONSE_SCHEMA
|
|
62
|
+
&& frame.request_id === requestId;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One live-task question, asked of the EventBus and answered by whatever is
|
|
67
|
+
* listening. Every failure mode is inert: the probe reports `false` and says why
|
|
68
|
+
* once, then lets the caller's own judgement stand.
|
|
69
|
+
*
|
|
70
|
+
* @param {{
|
|
71
|
+
* events?: { emit: (channel: string, data: unknown) => void, on: (channel: string, handler: (data: unknown) => void) => () => void } | null,
|
|
72
|
+
* getToolNames?: () => string[] | null,
|
|
73
|
+
* log?: (line: string) => void,
|
|
74
|
+
* timeoutMs?: number,
|
|
75
|
+
* }} options
|
|
76
|
+
*/
|
|
77
|
+
export function createBackgroundProbe({
|
|
78
|
+
events = null,
|
|
79
|
+
getToolNames = null,
|
|
80
|
+
log = () => {},
|
|
81
|
+
timeoutMs = DEFAULT_STATUS_TIMEOUT_MS,
|
|
82
|
+
} = {}) {
|
|
83
|
+
let installed = null;
|
|
84
|
+
const warned = new Set();
|
|
85
|
+
let unsubscribe = null;
|
|
86
|
+
|
|
87
|
+
const warnOnce = (reason) => {
|
|
88
|
+
if (warned.has(reason)) return;
|
|
89
|
+
warned.add(reason);
|
|
90
|
+
log(`background work: ${reason}`);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** True once one of the extension's tools is registered; cached either way. */
|
|
94
|
+
function isInstalled() {
|
|
95
|
+
if (installed !== null) return installed;
|
|
96
|
+
let names = null;
|
|
97
|
+
try {
|
|
98
|
+
names = typeof getToolNames === "function" ? getToolNames() : null;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
warnOnce(`tool list unreadable: ${error.message}`);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const list = Array.isArray(names) ? names : [];
|
|
104
|
+
installed = list.some(isBackgroundTool);
|
|
105
|
+
if (!installed) log("background work: no background-task extension in this session");
|
|
106
|
+
return installed;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* One status round trip. The answer arrives on the response channel, so the
|
|
111
|
+
* listener lives exactly as long as the wait and the timer bounds it.
|
|
112
|
+
* @returns {Promise<boolean>}
|
|
113
|
+
*/
|
|
114
|
+
function running() {
|
|
115
|
+
if (!isInstalled()) return Promise.resolve(false);
|
|
116
|
+
const bus = events;
|
|
117
|
+
if (!bus || typeof bus.emit !== "function" || typeof bus.on !== "function") {
|
|
118
|
+
warnOnce("event bus unavailable");
|
|
119
|
+
return Promise.resolve(false);
|
|
120
|
+
}
|
|
121
|
+
return new Promise((resolve) => {
|
|
122
|
+
const requestId = nextRequestId();
|
|
123
|
+
let settled = false;
|
|
124
|
+
let timer = null;
|
|
125
|
+
const finish = (answer) => {
|
|
126
|
+
if (settled) return;
|
|
127
|
+
settled = true;
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
if (typeof unsubscribe === "function") unsubscribe();
|
|
130
|
+
unsubscribe = null;
|
|
131
|
+
resolve(answer);
|
|
132
|
+
};
|
|
133
|
+
try {
|
|
134
|
+
unsubscribe = bus.on(RESPONSE_CHANNEL, (frame) => {
|
|
135
|
+
if (!isResponseFor(frame, requestId)) return;
|
|
136
|
+
if (frame.ok !== true) {
|
|
137
|
+
warnOnce(`status query refused: ${String(frame.error ?? "unknown")}`);
|
|
138
|
+
finish(false);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const tasks = frame.result?.tasks;
|
|
142
|
+
if (!Array.isArray(tasks)) {
|
|
143
|
+
warnOnce("status answer carried no task list");
|
|
144
|
+
finish(false);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
finish(tasks.some((task) => task?.status === LIVE_TASK_STATUS));
|
|
148
|
+
});
|
|
149
|
+
bus.emit(REQUEST_CHANNEL, {
|
|
150
|
+
schema_version: REQUEST_SCHEMA,
|
|
151
|
+
request_id: requestId,
|
|
152
|
+
operation: "status",
|
|
153
|
+
payload: {},
|
|
154
|
+
});
|
|
155
|
+
} catch (error) {
|
|
156
|
+
warnOnce(`status query failed: ${error.message}`);
|
|
157
|
+
finish(false);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
timer = setTimeout(() => {
|
|
161
|
+
warnOnce("status query timed out");
|
|
162
|
+
finish(false);
|
|
163
|
+
}, timeoutMs);
|
|
164
|
+
if (typeof timer?.unref === "function") timer.unref();
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
installed: isInstalled,
|
|
170
|
+
running,
|
|
171
|
+
close() {
|
|
172
|
+
if (typeof unsubscribe === "function") unsubscribe();
|
|
173
|
+
unsubscribe = null;
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|