@frockbot/plugin-shell 0.3.5 → 0.3.7
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/frockbot.json +1 -1
- package/package.json +31 -31
- package/src/backend-authoring.test.ts +72 -0
- package/src/backend-authoring.ts +82 -16
- package/src/backend-configuration.test.ts +6 -4
- package/src/backend-package-catalog.test.ts +23 -3
- package/src/backend-package-catalog.ts +7 -1
- package/src/backend-recovery-integration.test.ts +25 -32
- package/src/backend.ts +71 -6
- package/src/client/AppletCanvas.vue +3 -5
- package/src/client/FrockBotApp.vue +176 -106
- package/src/client/PackageIframeHost.vue +15 -6
- package/src/client/index.test.ts +101 -12
- package/src/client/index.ts +191 -59
- package/src/client/model-presentation.test.ts +4 -2
- package/src/client/model-presentation.ts +2 -2
- package/src/client/styles.css +15 -1
- package/src/connection-return.test.ts +55 -0
- package/src/history.test.ts +52 -0
- package/src/history.ts +79 -1
- package/src/run-protocol.ts +112 -5
- package/src/shared.ts +65 -0
package/frockbot.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/plugin-shell",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -47,41 +47,41 @@
|
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@cordisjs/plugin-webui": "0.8.2",
|
|
50
|
-
"@frockbot/agent-runtime": "0.3.
|
|
51
|
-
"@frockbot/application-foundation": "0.3.
|
|
52
|
-
"@frockbot/catalog-core": "0.3.
|
|
53
|
-
"@frockbot/client-core": "0.3.
|
|
54
|
-
"@frockbot/client-ui": "0.3.
|
|
55
|
-
"@frockbot/computer-core": "0.3.
|
|
56
|
-
"@frockbot/computer-host-protocol": "0.3.
|
|
57
|
-
"@frockbot/configuration-core": "0.3.
|
|
58
|
-
"@frockbot/connection-core": "0.3.
|
|
59
|
-
"@frockbot/kernel-agent-loop": "0.3.
|
|
60
|
-
"@frockbot/kernel-composition": "0.3.
|
|
61
|
-
"@frockbot/kernel-contracts": "0.3.
|
|
62
|
-
"@frockbot/kernel-do": "0.3.
|
|
63
|
-
"@frockbot/machine-protocol": "0.3.
|
|
64
|
-
"@frockbot/plugin-applets": "0.3.
|
|
65
|
-
"@frockbot/plugin-authoring": "0.3.
|
|
66
|
-
"@frockbot/plugin-bot-template": "0.3.
|
|
67
|
-
"@frockbot/plugin-computer": "0.3.
|
|
68
|
-
"@frockbot/plugin-flock": "0.3.
|
|
69
|
-
"@frockbot/plugin-image": "0.3.
|
|
70
|
-
"@frockbot/plugin-machine-messages": "0.3.
|
|
71
|
-
"@frockbot/plugin-mcp": "0.3.
|
|
72
|
-
"@frockbot/plugin-memory": "0.3.
|
|
73
|
-
"@frockbot/plugin-package-catalog": "0.3.
|
|
74
|
-
"@frockbot/plugin-routines": "0.3.
|
|
75
|
-
"@frockbot/plugin-skills": "0.3.
|
|
76
|
-
"@frockbot/plugin-subagents": "0.3.
|
|
77
|
-
"@frockbot/plugin-user-machine": "0.3.
|
|
78
|
-
"@frockbot/protocol": "0.3.
|
|
50
|
+
"@frockbot/agent-runtime": "0.3.7",
|
|
51
|
+
"@frockbot/application-foundation": "0.3.7",
|
|
52
|
+
"@frockbot/catalog-core": "0.3.7",
|
|
53
|
+
"@frockbot/client-core": "0.3.7",
|
|
54
|
+
"@frockbot/client-ui": "0.3.7",
|
|
55
|
+
"@frockbot/computer-core": "0.3.7",
|
|
56
|
+
"@frockbot/computer-host-protocol": "0.3.7",
|
|
57
|
+
"@frockbot/configuration-core": "0.3.7",
|
|
58
|
+
"@frockbot/connection-core": "0.3.7",
|
|
59
|
+
"@frockbot/kernel-agent-loop": "0.3.7",
|
|
60
|
+
"@frockbot/kernel-composition": "0.3.7",
|
|
61
|
+
"@frockbot/kernel-contracts": "0.3.7",
|
|
62
|
+
"@frockbot/kernel-do": "0.3.7",
|
|
63
|
+
"@frockbot/machine-protocol": "0.3.7",
|
|
64
|
+
"@frockbot/plugin-applets": "0.3.7",
|
|
65
|
+
"@frockbot/plugin-authoring": "0.3.7",
|
|
66
|
+
"@frockbot/plugin-bot-template": "0.3.7",
|
|
67
|
+
"@frockbot/plugin-computer": "0.3.7",
|
|
68
|
+
"@frockbot/plugin-flock": "0.3.7",
|
|
69
|
+
"@frockbot/plugin-image": "0.3.7",
|
|
70
|
+
"@frockbot/plugin-machine-messages": "0.3.7",
|
|
71
|
+
"@frockbot/plugin-mcp": "0.3.7",
|
|
72
|
+
"@frockbot/plugin-memory": "0.3.7",
|
|
73
|
+
"@frockbot/plugin-package-catalog": "0.3.7",
|
|
74
|
+
"@frockbot/plugin-routines": "0.3.7",
|
|
75
|
+
"@frockbot/plugin-skills": "0.3.7",
|
|
76
|
+
"@frockbot/plugin-subagents": "0.3.7",
|
|
77
|
+
"@frockbot/plugin-user-machine": "0.3.7",
|
|
78
|
+
"@frockbot/protocol": "0.3.7",
|
|
79
79
|
"cordis": "4.0.0-rc.8",
|
|
80
80
|
"vue": "3.5.41"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
83
|
"@cloudflare/workers-types": "latest",
|
|
84
|
-
"@frockbot/plugin-tools": "0.3.
|
|
84
|
+
"@frockbot/plugin-tools": "0.3.7",
|
|
85
85
|
"@types/bun": "1.3.6",
|
|
86
86
|
"@types/node": "26.2.0",
|
|
87
87
|
"@vitejs/plugin-vue": "6.0.8",
|
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
authorshipUndoIntentKey,
|
|
19
19
|
authorshipUndoOutcomeKey,
|
|
20
20
|
AUTHORSHIP_FAILURE_PREFIX,
|
|
21
|
+
classifyAuthoringEffectV1,
|
|
22
|
+
type AuthoringEffectOutcomeV1,
|
|
21
23
|
type AuthoredArtifactRecordV1,
|
|
22
24
|
type AuthoredManifestRecordV1,
|
|
23
25
|
type AuthoringFailureRecordV1,
|
|
@@ -234,9 +236,13 @@ describe("a Bot authoring a Package", () => {
|
|
|
234
236
|
quota?: (request: unknown) => unknown;
|
|
235
237
|
runId?: string;
|
|
236
238
|
currentToolNames?: readonly string[];
|
|
239
|
+
bundleTimeoutMs?: number;
|
|
237
240
|
}) {
|
|
238
241
|
let ids = 0;
|
|
239
242
|
return createPackageAuthoringHost({
|
|
243
|
+
...(options.bundleTimeoutMs === undefined
|
|
244
|
+
? {}
|
|
245
|
+
: { bundleTimeoutMs: options.bundleTimeoutMs }),
|
|
240
246
|
storage,
|
|
241
247
|
composition,
|
|
242
248
|
bundler: options.bundler as never,
|
|
@@ -911,4 +917,70 @@ describe("a Bot authoring a Package", () => {
|
|
|
911
917
|
}),
|
|
912
918
|
);
|
|
913
919
|
});
|
|
920
|
+
|
|
921
|
+
test("a bundler outage leaves the effect retryable, not poisoned", async () => {
|
|
922
|
+
// F11: the "could not be reached" refusal returned without writing any
|
|
923
|
+
// outcome, so `classifyAuthoringEffectV1` called that effectId `unknown`
|
|
924
|
+
// forever — "it will not be bundled again". Since the effectId is
|
|
925
|
+
// run + Package + source hash, retrying the identical source in the same
|
|
926
|
+
// run could never succeed.
|
|
927
|
+
const effectId = "author-0123456789abcdef";
|
|
928
|
+
const offline = {
|
|
929
|
+
calls: 0,
|
|
930
|
+
bundle() {
|
|
931
|
+
offline.calls += 1;
|
|
932
|
+
return Promise.reject(
|
|
933
|
+
new Error('Worker "frockbot-cloudflare-bundler" not found'),
|
|
934
|
+
);
|
|
935
|
+
},
|
|
936
|
+
};
|
|
937
|
+
const refusal = await host({ bundler: offline as never }).author(
|
|
938
|
+
requestFor(),
|
|
939
|
+
);
|
|
940
|
+
expect(refusal.status).toBe("refused");
|
|
941
|
+
|
|
942
|
+
const outcome = storage.values.get(
|
|
943
|
+
authorshipArtifactKey(effectId),
|
|
944
|
+
) as AuthoringEffectOutcomeV1;
|
|
945
|
+
expect(outcome).toMatchObject({ status: "unreachable", effectId });
|
|
946
|
+
expect(
|
|
947
|
+
classifyAuthoringEffectV1({
|
|
948
|
+
intent: storage.values.get(authorshipIntentKey(effectId)) as never,
|
|
949
|
+
outcome,
|
|
950
|
+
}),
|
|
951
|
+
).toEqual({ kind: "fresh" });
|
|
952
|
+
|
|
953
|
+
// And the identical source, in the same run, now goes through.
|
|
954
|
+
const recovered = await host({
|
|
955
|
+
bundler: countingBundler((id) => bundledResult(id)),
|
|
956
|
+
}).author(requestFor());
|
|
957
|
+
expect(recovered.status).toBe("authored");
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test("a bundler that never answers is refused at the deadline", async () => {
|
|
961
|
+
// F10: `bundle()` was awaited with no deadline and no AbortSignal, and the
|
|
962
|
+
// bundler runs esbuild-wasm inline — a hang there hung this tool call and
|
|
963
|
+
// the Turn around it, indefinitely.
|
|
964
|
+
const hanging = {
|
|
965
|
+
calls: 0,
|
|
966
|
+
bundle() {
|
|
967
|
+
hanging.calls += 1;
|
|
968
|
+
return new Promise<never>(() => {});
|
|
969
|
+
},
|
|
970
|
+
};
|
|
971
|
+
const outcome = await host({
|
|
972
|
+
bundler: hanging as never,
|
|
973
|
+
bundleTimeoutMs: 10,
|
|
974
|
+
}).author(requestFor());
|
|
975
|
+
|
|
976
|
+
expect(outcome).toMatchObject({ status: "refused" });
|
|
977
|
+
if (outcome.status !== "refused") return;
|
|
978
|
+
expect(outcome.reason).toContain(
|
|
979
|
+
"the Package bundler could not be reached: the Package bundler did not answer within 10ms",
|
|
980
|
+
);
|
|
981
|
+
// Nothing ran, so the effect stays retryable rather than poisoned (F11).
|
|
982
|
+
expect(
|
|
983
|
+
storage.values.get(authorshipArtifactKey("author-0123456789abcdef")),
|
|
984
|
+
).toMatchObject({ status: "unreachable" });
|
|
985
|
+
});
|
|
914
986
|
});
|
package/src/backend-authoring.ts
CHANGED
|
@@ -143,12 +143,45 @@ export interface PackageAuthoringHostOptions {
|
|
|
143
143
|
};
|
|
144
144
|
now?(): Date;
|
|
145
145
|
newId?(): string;
|
|
146
|
+
/** Overrides `PACKAGE_BUNDLE_TIMEOUT_MS_V1`; tests drive the deadline. */
|
|
147
|
+
bundleTimeoutMs?: number;
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
function errorMessage(error: unknown): string {
|
|
149
151
|
return error instanceof Error ? error.message : String(error);
|
|
150
152
|
}
|
|
151
153
|
|
|
154
|
+
/**
|
|
155
|
+
* How long the Package bundler has to answer. It runs esbuild-wasm inline, so
|
|
156
|
+
* a hang there is a hang of this tool call and of the Turn around it; the
|
|
157
|
+
* service binding itself imposes no deadline (finding F10). Generous, because
|
|
158
|
+
* a cold `createWorker` is slow, and far short of any Turn-level bound.
|
|
159
|
+
*/
|
|
160
|
+
const PACKAGE_BUNDLE_TIMEOUT_MS_V1 = 60_000;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `work`, or a rejection at the deadline. The pending work is abandoned, not
|
|
164
|
+
* cancelled: the bundler is stateless and side-effect free (its caller owns
|
|
165
|
+
* every durable write), so a late answer changes nothing.
|
|
166
|
+
*/
|
|
167
|
+
async function withDeadline<T>(
|
|
168
|
+
work: Promise<T>,
|
|
169
|
+
milliseconds: number,
|
|
170
|
+
message: string,
|
|
171
|
+
): Promise<T> {
|
|
172
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
173
|
+
try {
|
|
174
|
+
return await Promise.race([
|
|
175
|
+
work,
|
|
176
|
+
new Promise<never>((_resolve, reject) => {
|
|
177
|
+
timer = setTimeout(() => reject(new Error(message)), milliseconds);
|
|
178
|
+
}),
|
|
179
|
+
]);
|
|
180
|
+
} finally {
|
|
181
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
152
185
|
/**
|
|
153
186
|
* Builds the authoring seam one admitted Turn runs under. Constructed per
|
|
154
187
|
* Turn because provenance names the run and turn that produced the artifact.
|
|
@@ -691,18 +724,40 @@ export function createPackageAuthoringHost(
|
|
|
691
724
|
let bundled;
|
|
692
725
|
try {
|
|
693
726
|
// Never inside a storage transaction: bundler CPU is the bundler's.
|
|
727
|
+
// Bounded, because the bundler runs esbuild-wasm inline: one that
|
|
728
|
+
// hangs rather than rejects would hang this tool call and the Turn
|
|
729
|
+
// with it (F10). An expiry takes the same path as unreachability —
|
|
730
|
+
// nothing ran either way.
|
|
731
|
+
const deadline =
|
|
732
|
+
options.bundleTimeoutMs ?? PACKAGE_BUNDLE_TIMEOUT_MS_V1;
|
|
694
733
|
bundled = decodePackageBundleResultV1(
|
|
695
|
-
await
|
|
734
|
+
await withDeadline(
|
|
735
|
+
options.bundler.bundle(bundleRequest),
|
|
736
|
+
deadline,
|
|
737
|
+
`the Package bundler did not answer within ${deadline}ms`,
|
|
738
|
+
),
|
|
696
739
|
);
|
|
697
740
|
} catch (error) {
|
|
698
|
-
|
|
699
|
-
|
|
741
|
+
const failure = await recordFailure({
|
|
742
|
+
effectId,
|
|
743
|
+
packageId,
|
|
744
|
+
phase: "bundle",
|
|
745
|
+
reason: `the Package bundler could not be reached: ${errorMessage(error)}`,
|
|
746
|
+
});
|
|
747
|
+
// The bundler never answered, so nothing about this effect is
|
|
748
|
+
// uncertain. Recording that explicitly is what lets an identical
|
|
749
|
+
// retry in the same run proceed instead of classifying `unknown`
|
|
750
|
+
// forever (F11).
|
|
751
|
+
await options.storage.put({
|
|
752
|
+
[authorshipArtifactKey(effectId)]: {
|
|
753
|
+
schemaVersion: 1,
|
|
754
|
+
status: "unreachable",
|
|
700
755
|
effectId,
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
);
|
|
756
|
+
failureId: failure.failureId,
|
|
757
|
+
reason: failure.reason,
|
|
758
|
+
} satisfies AuthoringEffectOutcomeV1,
|
|
759
|
+
});
|
|
760
|
+
return refused(failure);
|
|
706
761
|
}
|
|
707
762
|
if (bundled.status === "failed") {
|
|
708
763
|
const failure = await recordFailure({
|
|
@@ -744,15 +799,26 @@ export function createPackageAuthoringHost(
|
|
|
744
799
|
);
|
|
745
800
|
}));
|
|
746
801
|
if (uiMismatch || (uiPages === undefined && returnedPages.length > 0)) {
|
|
747
|
-
|
|
748
|
-
|
|
802
|
+
const failure = await recordFailure({
|
|
803
|
+
effectId,
|
|
804
|
+
packageId,
|
|
805
|
+
phase: "bundle",
|
|
806
|
+
reason:
|
|
807
|
+
"the Package bundler returned UI bytes that do not match the recorded manifest",
|
|
808
|
+
});
|
|
809
|
+
// The bundler did answer, and deterministically: the same source
|
|
810
|
+
// would mismatch again. That is a settled failure, not an unknown —
|
|
811
|
+
// it too used to leave the effect with no outcome at all (F11).
|
|
812
|
+
await options.storage.put({
|
|
813
|
+
[authorshipArtifactKey(effectId)]: {
|
|
814
|
+
schemaVersion: 1,
|
|
815
|
+
status: "failed",
|
|
749
816
|
effectId,
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
);
|
|
817
|
+
failureId: failure.failureId,
|
|
818
|
+
reason: failure.reason,
|
|
819
|
+
} satisfies AuthoringEffectOutcomeV1,
|
|
820
|
+
});
|
|
821
|
+
return refused(failure);
|
|
756
822
|
}
|
|
757
823
|
// Content-addressed and immutable: writing the same hash twice is a
|
|
758
824
|
// no-op, so the object write is safe to repeat and the record is not.
|
|
@@ -702,10 +702,12 @@ describe("generic per-Turn model resolution", () => {
|
|
|
702
702
|
acceptedAt: "2026-09-02T00:01:00.000Z",
|
|
703
703
|
text: "must fail",
|
|
704
704
|
}),
|
|
705
|
-
).rejects.toThrow("
|
|
705
|
+
).rejects.toThrow("Turn this model's plugin back on in Plugins");
|
|
706
706
|
expect(await bot.storage.get(`run:${runId}`)).toMatchObject({
|
|
707
707
|
status: "failed",
|
|
708
|
-
failure: expect.stringContaining(
|
|
708
|
+
failure: expect.stringContaining(
|
|
709
|
+
"Turn this model's plugin back on in Plugins",
|
|
710
|
+
),
|
|
709
711
|
});
|
|
710
712
|
}
|
|
711
713
|
|
|
@@ -722,10 +724,10 @@ describe("generic per-Turn model resolution", () => {
|
|
|
722
724
|
acceptedAt: "2026-09-02T00:02:00.000Z",
|
|
723
725
|
text: "must fail",
|
|
724
726
|
}),
|
|
725
|
-
).rejects.toThrow("
|
|
727
|
+
).rejects.toThrow("needs reconnecting");
|
|
726
728
|
expect(await bot.storage.get(`run:${runId}`)).toMatchObject({
|
|
727
729
|
status: "failed",
|
|
728
|
-
failure: expect.stringContaining("
|
|
730
|
+
failure: expect.stringContaining("needs reconnecting"),
|
|
729
731
|
});
|
|
730
732
|
}
|
|
731
733
|
});
|
|
@@ -147,7 +147,12 @@ async function bootstrap(): Promise<CompositionGenerationV1> {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
async function fixture(
|
|
150
|
-
options: {
|
|
150
|
+
options: {
|
|
151
|
+
connected?: boolean;
|
|
152
|
+
uiAvailable?: boolean;
|
|
153
|
+
/** A deployment with no published Catalog pins no generation. */
|
|
154
|
+
pinned?: boolean;
|
|
155
|
+
} = {},
|
|
151
156
|
) {
|
|
152
157
|
const log: string[] = [];
|
|
153
158
|
const records = new Map<string, unknown>();
|
|
@@ -220,8 +225,9 @@ async function fixture(
|
|
|
220
225
|
},
|
|
221
226
|
]
|
|
222
227
|
: [],
|
|
223
|
-
|
|
224
|
-
|
|
228
|
+
...(options.pinned === false
|
|
229
|
+
? {}
|
|
230
|
+
: { catalogGeneration: "catalog-1", catalogIndexHash: "d".repeat(64) }),
|
|
225
231
|
};
|
|
226
232
|
const commands: UserConfigurationCommandV1[] = [];
|
|
227
233
|
const userAuthority = {
|
|
@@ -306,6 +312,20 @@ describe("Bot Package Catalog host", () => {
|
|
|
306
312
|
});
|
|
307
313
|
});
|
|
308
314
|
|
|
315
|
+
test("explains an unpinned Catalog in words a person can act on", async () => {
|
|
316
|
+
const { host } = await fixture({ pinned: false });
|
|
317
|
+
// F4: an unpinned User used to be told "this User has no pinned Package
|
|
318
|
+
// Catalog generation", which the Bot then relayed verbatim.
|
|
319
|
+
for (const call of [
|
|
320
|
+
() => host.search({ query: "shipping" }),
|
|
321
|
+
() => host.inspect({ catalogId: "parcel-tracking" }),
|
|
322
|
+
]) {
|
|
323
|
+
await expect(call()).rejects.toThrow(
|
|
324
|
+
"No Package Catalog is published for this deployment, so there is nothing to search or install",
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
309
329
|
test("persists the iframe manifest before the User effect, appends a summary generation, and replays", async () => {
|
|
310
330
|
const test = await fixture();
|
|
311
331
|
const request = {
|
|
@@ -145,7 +145,13 @@ function requiredCatalogPin(user: UserSettingsViewV1): {
|
|
|
145
145
|
indexHash: string;
|
|
146
146
|
} {
|
|
147
147
|
if (!user.catalogGeneration || !user.catalogIndexHash) {
|
|
148
|
-
|
|
148
|
+
// A User is pinned on the first configuration read that finds a published
|
|
149
|
+
// Catalog, so an absent pin means the deployment has none to find. Say
|
|
150
|
+
// that, rather than an internal sentence about pinning the Bot then
|
|
151
|
+
// relays to the person who asked.
|
|
152
|
+
throw new Error(
|
|
153
|
+
"No Package Catalog is published for this deployment, so there is nothing to search or install",
|
|
154
|
+
);
|
|
149
155
|
}
|
|
150
156
|
return {
|
|
151
157
|
generation: user.catalogGeneration,
|
|
@@ -354,26 +354,20 @@ describe("Bot recovery", () => {
|
|
|
354
354
|
text: "uncertain",
|
|
355
355
|
}),
|
|
356
356
|
).rejects.toThrow("response lost");
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
357
|
+
// Ollama keeps no addressable copy of a completion, so a failure raised
|
|
358
|
+
// before the first stream event is definitive rather than uncertain: the
|
|
359
|
+
// run settles as a failed Turn instead of parking on a retrieval this
|
|
360
|
+
// provider can never perform.
|
|
361
|
+
const uncertain = await storage.get<StoredRun>("run:ollama-run-uncertain");
|
|
362
|
+
expect(uncertain?.status).toBe("failed");
|
|
363
|
+
expect(uncertain?.failure).toContain("response lost");
|
|
364
|
+
expect(await storage.get("active-run")).toBeUndefined();
|
|
360
365
|
|
|
366
|
+
// The alarm has nothing left to recover: a settled run stays settled.
|
|
361
367
|
await host().alarm();
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
).
|
|
365
|
-
await expect(
|
|
366
|
-
host().reconcileRun(
|
|
367
|
-
{ userId: "user-1", botId: "primary" },
|
|
368
|
-
"ollama-run-uncertain",
|
|
369
|
-
),
|
|
370
|
-
).rejects.toThrow();
|
|
371
|
-
expect(
|
|
372
|
-
await storage.get<StoredRun>("run:ollama-run-uncertain"),
|
|
373
|
-
).toMatchObject({
|
|
374
|
-
status: "failed",
|
|
375
|
-
failure: expect.stringContaining("explicitly abandoned"),
|
|
376
|
-
});
|
|
368
|
+
const settled = await storage.get<StoredRun>("run:ollama-run-uncertain");
|
|
369
|
+
expect(settled?.status).toBe("failed");
|
|
370
|
+
expect(settled?.failure).toContain("response lost");
|
|
377
371
|
expect(await storage.get("active-run")).toBeUndefined();
|
|
378
372
|
});
|
|
379
373
|
|
|
@@ -626,7 +620,11 @@ describe("Bot recovery", () => {
|
|
|
626
620
|
expect(await recoveredAgain.listNotifications()).toEqual(notifications);
|
|
627
621
|
});
|
|
628
622
|
|
|
629
|
-
|
|
623
|
+
// ADR 0028. This used to park the run and hand the person a Resolve button.
|
|
624
|
+
// `provider-1` has no retrieval — none of the providers this deployment
|
|
625
|
+
// ships does — so there was never an answer to resolve it with, and the Bot
|
|
626
|
+
// stayed wedged behind it.
|
|
627
|
+
test("settles an unresolved request no provider can be asked about", async () => {
|
|
630
628
|
const storage = new MemoryStorage();
|
|
631
629
|
const settings = initializeBotSettingsV1("primary");
|
|
632
630
|
const events = [
|
|
@@ -690,19 +688,14 @@ describe("Bot recovery", () => {
|
|
|
690
688
|
env: {} as never,
|
|
691
689
|
});
|
|
692
690
|
|
|
693
|
-
await
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
}),
|
|
702
|
-
],
|
|
703
|
-
page: { truncated: false },
|
|
704
|
-
});
|
|
705
|
-
expect(storage.values.get("active-run")).toBe("run-lost-marker");
|
|
691
|
+
await recovered.listRuns();
|
|
692
|
+
|
|
693
|
+
const settled = storage.values.get("run:run-lost-marker") as StoredRun;
|
|
694
|
+
expect(settled.status).toBe("failed");
|
|
695
|
+
expect(settled.failure).toContain("stopped partway");
|
|
696
|
+
// The Bot is released: nothing is holding the next Turn behind a decision
|
|
697
|
+
// nobody can make.
|
|
698
|
+
expect(storage.values.get("active-run")).toBeUndefined();
|
|
706
699
|
expect(storage.alarmAt).toBeUndefined();
|
|
707
700
|
});
|
|
708
701
|
|
package/src/backend.ts
CHANGED
|
@@ -403,6 +403,7 @@ import {
|
|
|
403
403
|
projectClientAnnouncementsV1,
|
|
404
404
|
projectClientTurnV1,
|
|
405
405
|
type ClientRunLookupV1,
|
|
406
|
+
type ClientConversationListV1,
|
|
406
407
|
type ClientRunListV1,
|
|
407
408
|
type ClientRunStopReceiptV1,
|
|
408
409
|
type ClientRunV1,
|
|
@@ -446,6 +447,25 @@ import {
|
|
|
446
447
|
} from "./unread.js";
|
|
447
448
|
import { defineBotBackendContribution } from "@frockbot/kernel-contracts/contributions";
|
|
448
449
|
|
|
450
|
+
/**
|
|
451
|
+
* The providers that can be asked what happened to a model request they never
|
|
452
|
+
* answered — that is, the ones whose Package registers an
|
|
453
|
+
* `LlmReconciliationCapability` (ADR 0028).
|
|
454
|
+
*
|
|
455
|
+
* It is a list rather than a lookup because recovery consults it inside the
|
|
456
|
+
* durable transaction that settles the run, where nothing may be mounted or
|
|
457
|
+
* awaited. The cost is that a provider Package which gains retrieval has to
|
|
458
|
+
* name itself here; the ADR carries that obligation, and the failure mode of
|
|
459
|
+
* forgetting is a Turn settled as failed rather than one wedged forever, which
|
|
460
|
+
* is the direction this deployment wants to be wrong in.
|
|
461
|
+
*
|
|
462
|
+
* Today: the in-process foundation provider, and nothing else. Ollama Cloud
|
|
463
|
+
* exposes no provider-bound retrieval (ADR 0010) and neither does Flock AI.
|
|
464
|
+
*/
|
|
465
|
+
const RECONCILING_PROVIDER_IDS_V1: ReadonlySet<string> = new Set([
|
|
466
|
+
"foundation",
|
|
467
|
+
]);
|
|
468
|
+
|
|
449
469
|
export const BOT_CONFIGURATION_KEY = "bot-configuration";
|
|
450
470
|
const CONFIGURATION_RECEIPT_PREFIX = "configuration-receipt:";
|
|
451
471
|
const STOP_RECEIPT_PREFIX = "stop-receipt:";
|
|
@@ -784,6 +804,8 @@ export class ShellBotBackendContribution {
|
|
|
784
804
|
deferScheduledWork: (transaction) =>
|
|
785
805
|
this.deferScheduledWork(transaction),
|
|
786
806
|
settleScheduledWork: () => this.settleScheduledWork(),
|
|
807
|
+
providerReconciles: (providerId) =>
|
|
808
|
+
RECONCILING_PROVIDER_IDS_V1.has(providerId),
|
|
787
809
|
},
|
|
788
810
|
});
|
|
789
811
|
}
|
|
@@ -4572,13 +4594,13 @@ export class ShellBotBackendContribution {
|
|
|
4572
4594
|
if (!effectiveModel) {
|
|
4573
4595
|
throw new Error(
|
|
4574
4596
|
effective.binding?.failure ??
|
|
4575
|
-
"No model is
|
|
4597
|
+
"No model is set up yet. Choose one in Models.",
|
|
4576
4598
|
);
|
|
4577
4599
|
}
|
|
4578
4600
|
const binding: ResolvedModelBindingV1 = effective.binding ?? {
|
|
4579
4601
|
model: structuredClone(effectiveModel),
|
|
4580
4602
|
state: "unavailable",
|
|
4581
|
-
failure: "
|
|
4603
|
+
failure: "This Bot's model isn't available. Pick one in Models.",
|
|
4582
4604
|
};
|
|
4583
4605
|
if (
|
|
4584
4606
|
binding.state === "unavailable" ||
|
|
@@ -4587,7 +4609,8 @@ export class ShellBotBackendContribution {
|
|
|
4587
4609
|
!binding.packageId
|
|
4588
4610
|
) {
|
|
4589
4611
|
throw new Error(
|
|
4590
|
-
binding.failure ??
|
|
4612
|
+
binding.failure ??
|
|
4613
|
+
"This Bot's model isn't available. Pick one in Models.",
|
|
4591
4614
|
);
|
|
4592
4615
|
}
|
|
4593
4616
|
if (
|
|
@@ -4600,7 +4623,9 @@ export class ShellBotBackendContribution {
|
|
|
4600
4623
|
admittedRequest.modelBinding.connectionGeneration !==
|
|
4601
4624
|
binding.connection.generation)
|
|
4602
4625
|
) {
|
|
4603
|
-
throw new Error(
|
|
4626
|
+
throw new Error(
|
|
4627
|
+
"This Bot's model changed mid-reply. Send your message again.",
|
|
4628
|
+
);
|
|
4604
4629
|
}
|
|
4605
4630
|
const bindingPackageId = binding.packageId;
|
|
4606
4631
|
agentPackages.push(
|
|
@@ -5419,6 +5444,15 @@ export class ShellBotBackendContribution {
|
|
|
5419
5444
|
): Promise<ClientRunListV1> {
|
|
5420
5445
|
const query = decodeClientRunListQueryV1(input);
|
|
5421
5446
|
await this.authority.recoverActiveRun();
|
|
5447
|
+
// The transcript is one conversation, not every Turn the Bot has ever
|
|
5448
|
+
// run. Absent means the conversation the Bot is on; naming an earlier one
|
|
5449
|
+
// reads it exactly as it was left. A Bot whose object has not learned its
|
|
5450
|
+
// identity yet has no conversation to filter by and shows what it has.
|
|
5451
|
+
const conversationId =
|
|
5452
|
+
query.conversationId ??
|
|
5453
|
+
(await this.authority.readConversationSessionId());
|
|
5454
|
+
const inConversation = (run: { sessionId: string }) =>
|
|
5455
|
+
conversationId === undefined || run.sessionId === conversationId;
|
|
5422
5456
|
const activeRunId = query.before
|
|
5423
5457
|
? undefined
|
|
5424
5458
|
: await this.authority.readActiveRunId();
|
|
@@ -5433,7 +5467,7 @@ export class ShellBotBackendContribution {
|
|
|
5433
5467
|
// An automation firing occupies the object like any other run, and is
|
|
5434
5468
|
// still not part of the conversation: the visible transcript never
|
|
5435
5469
|
// shows one, running or settled.
|
|
5436
|
-
if (active && isVisibleRunV1(active))
|
|
5470
|
+
if (active && isVisibleRunV1(active) && inConversation(active))
|
|
5437
5471
|
selected.set(active.runId, {
|
|
5438
5472
|
run: projectClientRunOrDegradedV1(active),
|
|
5439
5473
|
});
|
|
@@ -5447,7 +5481,8 @@ export class ShellBotBackendContribution {
|
|
|
5447
5481
|
continue;
|
|
5448
5482
|
}
|
|
5449
5483
|
const stored = await this.authority.readStoredRun(candidate.runId);
|
|
5450
|
-
if (!stored || !isVisibleRunV1(stored))
|
|
5484
|
+
if (!stored || !isVisibleRunV1(stored) || !inConversation(stored))
|
|
5485
|
+
continue;
|
|
5451
5486
|
const projected = projectClientRunOrDegradedV1(stored);
|
|
5452
5487
|
const tentative = [
|
|
5453
5488
|
...selected.values(),
|
|
@@ -5503,6 +5538,36 @@ export class ShellBotBackendContribution {
|
|
|
5503
5538
|
}
|
|
5504
5539
|
return page;
|
|
5505
5540
|
}
|
|
5541
|
+
/** The conversations this Bot has had, newest first. */
|
|
5542
|
+
async listConversations(): Promise<ClientConversationListV1> {
|
|
5543
|
+
return {
|
|
5544
|
+
schemaVersion: 1,
|
|
5545
|
+
conversations: (await this.authority.listConversations()).map(
|
|
5546
|
+
(conversation) => ({
|
|
5547
|
+
schemaVersion: 1 as const,
|
|
5548
|
+
conversationId: conversation.sessionId,
|
|
5549
|
+
ordinal: conversation.ordinal,
|
|
5550
|
+
startedAt: conversation.startedAt,
|
|
5551
|
+
...(conversation.endedAt ? { endedAt: conversation.endedAt } : {}),
|
|
5552
|
+
}),
|
|
5553
|
+
),
|
|
5554
|
+
};
|
|
5555
|
+
}
|
|
5556
|
+
|
|
5557
|
+
/**
|
|
5558
|
+
* Puts this conversation down and starts the next one.
|
|
5559
|
+
*
|
|
5560
|
+
* Memory is untouched: it is not conversation history, and the point of a
|
|
5561
|
+
* new conversation is to prove that it is not.
|
|
5562
|
+
*/
|
|
5563
|
+
async startConversation(
|
|
5564
|
+
identity: BotIdentity,
|
|
5565
|
+
): Promise<ClientConversationListV1> {
|
|
5566
|
+
await this.validateIdentity(identity);
|
|
5567
|
+
await this.authority.startConversation(identity);
|
|
5568
|
+
return this.listConversations();
|
|
5569
|
+
}
|
|
5570
|
+
|
|
5506
5571
|
async lookupRun(input: unknown): Promise<ClientRunLookupV1> {
|
|
5507
5572
|
const query = decodeClientRunLookupQueryV1(input);
|
|
5508
5573
|
return projectClientRunLookupV1(await this.authority.readRun(query.runId));
|
|
@@ -147,7 +147,7 @@ function generationLabel(generationId: string): string {
|
|
|
147
147
|
hour: "numeric",
|
|
148
148
|
minute: "2-digit",
|
|
149
149
|
});
|
|
150
|
-
return `Live since ${when}
|
|
150
|
+
return `Live since ${when}`;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
function selectTab(next: "app" | "code"): void {
|
|
@@ -199,10 +199,8 @@ async function clearFocus(): Promise<void> {
|
|
|
199
199
|
/></span>
|
|
200
200
|
<div class="applet-canvas-title">
|
|
201
201
|
<strong>{{ applet?.displayName ?? "Applet" }}</strong>
|
|
202
|
-
<small v-if="viewer"
|
|
203
|
-
|
|
204
|
-
}}</small>
|
|
205
|
-
<small v-else>No published version yet</small>
|
|
202
|
+
<small v-if="viewer">{{ generationLabel(viewer.generationId) }}</small>
|
|
203
|
+
<small v-else>Not published yet</small>
|
|
206
204
|
</div>
|
|
207
205
|
<div
|
|
208
206
|
v-if="canShowApp"
|