@frockbot/plugin-shell 0.3.4 → 0.3.6
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 +23 -23
- package/src/backend.ts +119 -29
- package/src/client/AppletCanvas.vue +3 -5
- package/src/client/FrockBotApp.vue +197 -79
- package/src/client/PackageIframeHost.vue +15 -6
- package/src/client/index.test.ts +111 -15
- package/src/client/index.ts +217 -43
- package/src/client/model-presentation.test.ts +4 -2
- package/src/client/model-presentation.ts +2 -2
- package/src/client/styles.css +76 -1
- package/src/run-protocol.test.ts +73 -0
- package/src/run-protocol.ts +161 -9
- package/src/shared.ts +6 -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.6",
|
|
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.6",
|
|
51
|
+
"@frockbot/application-foundation": "0.3.6",
|
|
52
|
+
"@frockbot/catalog-core": "0.3.6",
|
|
53
|
+
"@frockbot/client-core": "0.3.6",
|
|
54
|
+
"@frockbot/client-ui": "0.3.6",
|
|
55
|
+
"@frockbot/computer-core": "0.3.6",
|
|
56
|
+
"@frockbot/computer-host-protocol": "0.3.6",
|
|
57
|
+
"@frockbot/configuration-core": "0.3.6",
|
|
58
|
+
"@frockbot/connection-core": "0.3.6",
|
|
59
|
+
"@frockbot/kernel-agent-loop": "0.3.6",
|
|
60
|
+
"@frockbot/kernel-composition": "0.3.6",
|
|
61
|
+
"@frockbot/kernel-contracts": "0.3.6",
|
|
62
|
+
"@frockbot/kernel-do": "0.3.6",
|
|
63
|
+
"@frockbot/machine-protocol": "0.3.6",
|
|
64
|
+
"@frockbot/plugin-applets": "0.3.6",
|
|
65
|
+
"@frockbot/plugin-authoring": "0.3.6",
|
|
66
|
+
"@frockbot/plugin-bot-template": "0.3.6",
|
|
67
|
+
"@frockbot/plugin-computer": "0.3.6",
|
|
68
|
+
"@frockbot/plugin-flock": "0.3.6",
|
|
69
|
+
"@frockbot/plugin-image": "0.3.6",
|
|
70
|
+
"@frockbot/plugin-machine-messages": "0.3.6",
|
|
71
|
+
"@frockbot/plugin-mcp": "0.3.6",
|
|
72
|
+
"@frockbot/plugin-memory": "0.3.6",
|
|
73
|
+
"@frockbot/plugin-package-catalog": "0.3.6",
|
|
74
|
+
"@frockbot/plugin-routines": "0.3.6",
|
|
75
|
+
"@frockbot/plugin-skills": "0.3.6",
|
|
76
|
+
"@frockbot/plugin-subagents": "0.3.6",
|
|
77
|
+
"@frockbot/plugin-user-machine": "0.3.6",
|
|
78
|
+
"@frockbot/protocol": "0.3.6",
|
|
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.6",
|
|
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,
|
|
@@ -626,7 +626,11 @@ describe("Bot recovery", () => {
|
|
|
626
626
|
expect(await recoveredAgain.listNotifications()).toEqual(notifications);
|
|
627
627
|
});
|
|
628
628
|
|
|
629
|
-
|
|
629
|
+
// ADR 0028. This used to park the run and hand the person a Resolve button.
|
|
630
|
+
// `provider-1` has no retrieval — none of the providers this deployment
|
|
631
|
+
// ships does — so there was never an answer to resolve it with, and the Bot
|
|
632
|
+
// stayed wedged behind it.
|
|
633
|
+
test("settles an unresolved request no provider can be asked about", async () => {
|
|
630
634
|
const storage = new MemoryStorage();
|
|
631
635
|
const settings = initializeBotSettingsV1("primary");
|
|
632
636
|
const events = [
|
|
@@ -690,19 +694,14 @@ describe("Bot recovery", () => {
|
|
|
690
694
|
env: {} as never,
|
|
691
695
|
});
|
|
692
696
|
|
|
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");
|
|
697
|
+
await recovered.listRuns();
|
|
698
|
+
|
|
699
|
+
const settled = storage.values.get("run:run-lost-marker") as StoredRun;
|
|
700
|
+
expect(settled.status).toBe("failed");
|
|
701
|
+
expect(settled.failure).toContain("stopped partway");
|
|
702
|
+
// The Bot is released: nothing is holding the next Turn behind a decision
|
|
703
|
+
// nobody can make.
|
|
704
|
+
expect(storage.values.get("active-run")).toBeUndefined();
|
|
706
705
|
expect(storage.alarmAt).toBeUndefined();
|
|
707
706
|
});
|
|
708
707
|
|
|
@@ -1310,17 +1309,18 @@ describe("Bot recovery", () => {
|
|
|
1310
1309
|
{ schemaVersion: 1, runId: `bounded-fence-${index}` },
|
|
1311
1310
|
);
|
|
1312
1311
|
}
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1312
|
+
// The index is a bounded FIFO: the oldest fence ages out, and the fence
|
|
1313
|
+
// itself always succeeds. Refusing it instead meant a Bot that had refused
|
|
1314
|
+
// 256 sends answered every later fence 500, and left the client looping
|
|
1315
|
+
// "Turn admission lookup failed".
|
|
1316
|
+
await contribution.fenceRunAdmission(
|
|
1317
|
+
{ userId: "user-1", botId: "primary" },
|
|
1318
|
+
{ schemaVersion: 1, runId: "fence-over-capacity" },
|
|
1319
|
+
);
|
|
1319
1320
|
const fences = await storage.get<string[]>("run-admission-fences");
|
|
1320
1321
|
expect(fences).toHaveLength(256);
|
|
1321
|
-
expect(fences).toContain("
|
|
1322
|
-
expect(fences).toContain("
|
|
1323
|
-
expect(fences).not.toContain("fence-over-capacity");
|
|
1322
|
+
expect(fences).toContain("fence-over-capacity");
|
|
1323
|
+
expect(fences).not.toContain("command-fenced");
|
|
1324
1324
|
});
|
|
1325
1325
|
|
|
1326
1326
|
test("rechecks a fence committed during execution-context resolution", async () => {
|