@intentic/sandbox-contract 1.250.0 → 1.252.0
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 +7 -0
- package/dist/contracts/ci.contract.d.ts +4 -0
- package/dist/contracts/ci.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +7 -0
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/ids/conversation-ids.d.ts +15 -0
- package/dist/ids/conversation-ids.d.ts.map +1 -1
- package/dist/ids/conversation-ids.js +30 -0
- package/dist/ids/conversation-ids.js.map +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/schemas/ci.d.ts +4 -0
- package/dist/schemas/ci.d.ts.map +1 -1
- package/dist/schemas/ci.js +4 -0
- package/dist/schemas/ci.js.map +1 -1
- package/dist/schemas/devices.d.ts +19 -0
- package/dist/schemas/devices.d.ts.map +1 -1
- package/dist/schemas/devices.js +20 -1
- package/dist/schemas/devices.js.map +1 -1
- package/dist/state/fix-attempt-plan.d.ts +24 -0
- package/dist/state/fix-attempt-plan.d.ts.map +1 -0
- package/dist/state/fix-attempt-plan.js +22 -0
- package/dist/state/fix-attempt-plan.js.map +1 -0
- package/dist/state/fix-stance.d.ts +11 -0
- package/dist/state/fix-stance.d.ts.map +1 -0
- package/dist/state/fix-stance.js +79 -0
- package/dist/state/fix-stance.js.map +1 -0
- package/package.json +4 -4
- package/src/ids/conversation-ids.test.ts +61 -1
- package/src/ids/conversation-ids.ts +58 -1
- package/src/index.ts +2 -0
- package/src/schemas/ci.ts +8 -0
- package/src/schemas/devices.ts +38 -1
- package/src/state/fix-attempt-plan.test.ts +64 -0
- package/src/state/fix-attempt-plan.ts +54 -0
- package/src/state/fix-stance.test.ts +99 -0
- package/src/state/fix-stance.ts +140 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fix-stance.d.ts","sourceRoot":"","sources":["../../src/state/fix-stance.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAYzD,MAAM,MAAM,aAAa,GAEnB,SAAS,GAET,WAAW,GAEX,OAAO,GAEP,QAAQ,GAER,SAAS,GAET,OAAO,CAAC;AAEd,MAAM,WAAW,SAAS;IACtB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAE7B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAE1B,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAEvB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACzB;AAgGD,eAAO,MAAM,SAAS,UAAW,YAAY,KAAG,SAQ/C,CAAC"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
const needsYou = (label, hint) => ({ kind: "needs-you", ongoing: true, retry: false, label, hint });
|
|
2
|
+
const ATTENTION = [
|
|
3
|
+
{ flag: "plan", label: "Approval needed", why: "it has proposed a plan and is waiting for a yes" },
|
|
4
|
+
{ flag: "capability", label: "Setup needed", why: "it needs something connected that is not connected yet" },
|
|
5
|
+
{ flag: "question", label: "Question for you", why: "it has asked you something" },
|
|
6
|
+
{ flag: "permission", label: "Permission needed", why: "it wants to use a tool it needs permission for" },
|
|
7
|
+
{ flag: "conflict", label: "Land conflict", why: "its work cannot be merged until somebody resolves a clash" },
|
|
8
|
+
];
|
|
9
|
+
const IN_FLIGHT = new Set(["running", "resuming", "stopping", "dismissing"]);
|
|
10
|
+
const WORKING = {
|
|
11
|
+
kind: "working",
|
|
12
|
+
ongoing: true,
|
|
13
|
+
retry: false,
|
|
14
|
+
label: "Agent working",
|
|
15
|
+
hint: "An agent is already working on this failure. Open the conversation to watch it.",
|
|
16
|
+
};
|
|
17
|
+
const READY = {
|
|
18
|
+
kind: "ready",
|
|
19
|
+
ongoing: true,
|
|
20
|
+
retry: false,
|
|
21
|
+
label: "Fix ready",
|
|
22
|
+
hint: "The fix is written and held on the agent's branch. Open it to review the diff and land it.",
|
|
23
|
+
};
|
|
24
|
+
const LANDED = {
|
|
25
|
+
kind: "landed",
|
|
26
|
+
ongoing: false,
|
|
27
|
+
retry: false,
|
|
28
|
+
label: "Fix landed",
|
|
29
|
+
hint: "The fix is in your workspace. Running the check again is what proves it.",
|
|
30
|
+
};
|
|
31
|
+
const WAITING = {
|
|
32
|
+
kind: "waiting",
|
|
33
|
+
ongoing: true,
|
|
34
|
+
retry: false,
|
|
35
|
+
label: "Waiting",
|
|
36
|
+
hint: "The fix agent's allowance is spent. Nothing is owed: the turn goes again when the provider's window reopens.",
|
|
37
|
+
};
|
|
38
|
+
const holdingWork = (agent) => agent.status === "ready" || (agent.status === "idle" && agent.diff !== undefined && agent.diff.files > 0);
|
|
39
|
+
const ENDINGS = {
|
|
40
|
+
error: { label: "Agent failed", why: "The fix agent's turn failed" },
|
|
41
|
+
interrupted: { label: "Interrupted", why: "The fix agent's turn was cut off when the sandbox went away" },
|
|
42
|
+
stopped: { label: "Stopped", why: "The fix agent was stopped before it finished" },
|
|
43
|
+
idle: { label: "Nothing changed", why: "The fix agent finished without changing any files" },
|
|
44
|
+
};
|
|
45
|
+
const ended = (agent) => {
|
|
46
|
+
const ending = ENDINGS[agent.status] ?? { label: "Agent stopped", why: "The fix agent's turn ended" };
|
|
47
|
+
const files = agent.diff?.files ?? 0;
|
|
48
|
+
const partial = files === 0 ? "" : ` It left ${files} changed file${files === 1 ? "" : "s"} on its branch.`;
|
|
49
|
+
return {
|
|
50
|
+
kind: "ended",
|
|
51
|
+
ongoing: false,
|
|
52
|
+
retry: true,
|
|
53
|
+
label: ending.label,
|
|
54
|
+
hint: `${ending.why}${agent.failure === undefined ? "" : `: ${agent.failure}`}.${partial} Continuing carries on in the same conversation; starting over opens a fresh one.`,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
const RULES = [
|
|
58
|
+
(agent) => (agent.status === "error" && agent.failureCode === "rate_limit" ? WAITING : undefined),
|
|
59
|
+
(agent) => {
|
|
60
|
+
const parked = ATTENTION.find((entry) => agent.attention[entry.flag]);
|
|
61
|
+
return parked === undefined ? undefined : needsYou(parked.label, `The fix agent is waiting on you: ${parked.why}.`);
|
|
62
|
+
},
|
|
63
|
+
(agent) => agent.status === "awaiting" || agent.status === "conflict"
|
|
64
|
+
? needsYou("Needs you", "The fix agent has stopped and is waiting on you.")
|
|
65
|
+
: undefined,
|
|
66
|
+
(agent) => (IN_FLIGHT.has(agent.status) ? WORKING : undefined),
|
|
67
|
+
(agent) => (holdingWork(agent) ? READY : undefined),
|
|
68
|
+
(agent) => (agent.status === "landed" ? LANDED : undefined),
|
|
69
|
+
];
|
|
70
|
+
export const fixStance = (agent) => {
|
|
71
|
+
for (const rule of RULES) {
|
|
72
|
+
const stance = rule(agent);
|
|
73
|
+
if (stance !== undefined) {
|
|
74
|
+
return stance;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return ended(agent);
|
|
78
|
+
};
|
|
79
|
+
//# sourceMappingURL=fix-stance.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fix-stance.js","sourceRoot":"","sources":["../../src/state/fix-stance.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentic/sandbox-contract",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.252.0",
|
|
4
4
|
"description": "oRPC wire contract for the intentic sandbox daemon, shared by the daemon and its browser client",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -185,9 +185,9 @@
|
|
|
185
185
|
}
|
|
186
186
|
},
|
|
187
187
|
"dependencies": {
|
|
188
|
-
"@intentic/constants": "1.
|
|
189
|
-
"@intentic/extension-manifest": "1.
|
|
190
|
-
"@intentic/registry": "1.
|
|
188
|
+
"@intentic/constants": "1.252.0",
|
|
189
|
+
"@intentic/extension-manifest": "1.252.0",
|
|
190
|
+
"@intentic/registry": "1.252.0",
|
|
191
191
|
"@orpc/contract": "1.14.13",
|
|
192
192
|
"tslib": "2.8.1",
|
|
193
193
|
"zod": "4.5.4"
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { afterEach, expect, test, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
CI_FIX_PREFIX,
|
|
4
|
+
ciFixConversationId,
|
|
5
|
+
fixAttemptId,
|
|
6
|
+
fixAttemptOf,
|
|
7
|
+
fixAttemptsOf,
|
|
8
|
+
latestFixAttempt,
|
|
9
|
+
newConversationId,
|
|
10
|
+
nextFixAttemptId,
|
|
11
|
+
PUSH_FIX_PREFIX,
|
|
12
|
+
pushFixConversationId,
|
|
13
|
+
} from "./conversation-ids.js";
|
|
3
14
|
import { ConversationIdSchema } from "../schemas/agent.js";
|
|
4
15
|
|
|
5
16
|
const mockRandomValues = (values: readonly number[]) => {
|
|
@@ -101,3 +112,52 @@ test("every derived push fix id passes the conversation-id guard", () => {
|
|
|
101
112
|
}
|
|
102
113
|
}
|
|
103
114
|
});
|
|
115
|
+
|
|
116
|
+
// Attempt 1 is the bare derived id, so every fix conversation minted before attempts existed reads as attempt 1.
|
|
117
|
+
test("the first attempt wears the failure's own id, later ones carry their number", () => {
|
|
118
|
+
const base = ciFixConversationId(`web`, 41);
|
|
119
|
+
expect(fixAttemptId(base, 1)).toBe(base);
|
|
120
|
+
expect(fixAttemptId(base, 2)).toBe(`ci-fix-web-41-attempt2`);
|
|
121
|
+
expect(fixAttemptOf(base, base)).toBe(1);
|
|
122
|
+
expect(fixAttemptOf(base, `ci-fix-web-41-attempt2`)).toBe(2);
|
|
123
|
+
expect(fixAttemptOf(base, `ci-fix-web-41-attempt12`)).toBe(12);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Read base-relative on purpose: a base ends in a run id, so a neighbouring failure's id can start with this one.
|
|
127
|
+
test("another failure's id is not an attempt at this one", () => {
|
|
128
|
+
const base = ciFixConversationId(`web`, 41);
|
|
129
|
+
// Repo `web-41`, run 2: starts with the base and a dash, and is nobody's attempt.
|
|
130
|
+
expect(fixAttemptOf(base, ciFixConversationId(`web-41`, 2))).toBeUndefined();
|
|
131
|
+
expect(fixAttemptOf(base, ciFixConversationId(`web`, 412))).toBeUndefined();
|
|
132
|
+
expect(fixAttemptOf(base, `swift-otter-k9m2`)).toBeUndefined();
|
|
133
|
+
// One spelling per attempt.
|
|
134
|
+
expect(fixAttemptOf(base, `ci-fix-web-41-attempt1`)).toBeUndefined();
|
|
135
|
+
expect(fixAttemptOf(base, `ci-fix-web-41-attempt02`)).toBeUndefined();
|
|
136
|
+
expect(fixAttemptOf(base, `ci-fix-web-41-attempt`)).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("attempts are read off the roster, earliest first, and the newest is the live answer", () => {
|
|
140
|
+
const base = pushFixConversationId(`intentic`, `lint`);
|
|
141
|
+
const roster = [{ id: `swift-otter-k9m2` }, { id: fixAttemptId(base, 3) }, { id: base }, { id: pushFixConversationId(`intentic`, `typecheck`) }];
|
|
142
|
+
expect(fixAttemptsOf(base, roster).map((entry) => entry.attempt)).toEqual([1, 3]);
|
|
143
|
+
expect(latestFixAttempt(base, roster)?.agent.id).toBe(fixAttemptId(base, 3));
|
|
144
|
+
expect(latestFixAttempt(base, [])).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// The archive counts: a start-over files the last attempt away, and re-minting its number would un-archive it.
|
|
148
|
+
test("the next attempt is numbered past every known one, archived included", () => {
|
|
149
|
+
const base = ciFixConversationId(`web`, 41);
|
|
150
|
+
expect(nextFixAttemptId(base, [])).toBe(base);
|
|
151
|
+
expect(nextFixAttemptId(base, [base])).toBe(`ci-fix-web-41-attempt2`);
|
|
152
|
+
expect(nextFixAttemptId(base, [`ci-fix-web-41-attempt3`, `swift-otter-k9m2`])).toBe(`ci-fix-web-41-attempt4`);
|
|
153
|
+
expect(nextFixAttemptId(base, [ciFixConversationId(`web-41`, 2)])).toBe(base);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("an attempt id passes the guard and fits, even for the widest run id a forge mints", () => {
|
|
157
|
+
const widest = ciFixConversationId(`a-repository-name-of-thirty-two-chars-or-more`, 99_999_999_999);
|
|
158
|
+
for (const attempt of [1, 2, 99]) {
|
|
159
|
+
const id = fixAttemptId(widest, attempt);
|
|
160
|
+
expect(ConversationIdSchema.safeParse(id).success).toBe(true);
|
|
161
|
+
expect(id.length).toBeLessThanOrEqual(64);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
@@ -149,7 +149,8 @@ export const newConversationId = (): string => `${pick(ADJECTIVES)}-${pick(NOUNS
|
|
|
149
149
|
// Derived, not drawn, so a re-computed id says whether an agent is already on this failure and a second press continues
|
|
150
150
|
// it. The repo is included (one run id per project) and slugified, not hashed, since people read it.
|
|
151
151
|
export const CI_FIX_PREFIX = "ci-fix-";
|
|
152
|
-
// Leaves ~24 characters for the run id within the 64-char id limit
|
|
152
|
+
// Leaves ~24 characters for the run id and an attempt suffix within the 64-char id limit; the widest run id either
|
|
153
|
+
// forge mints is eleven digits.
|
|
153
154
|
const REPO_SLUG_MAX = 32;
|
|
154
155
|
const repoSlug = (repo: string): string =>
|
|
155
156
|
repo
|
|
@@ -176,3 +177,59 @@ const digest = (text: string): string => {
|
|
|
176
177
|
};
|
|
177
178
|
|
|
178
179
|
export const pushFixConversationId = (scope: string, signature: string): string => `${PUSH_FIX_PREFIX}${repoSlug(scope)}-${digest(signature)}`;
|
|
180
|
+
|
|
181
|
+
// ONE FAILURE, MANY ATTEMPTS, ONE LIVE ANSWER. The derived id above names the FAILURE; an attempt at it is a
|
|
182
|
+
// conversation of its own, so starting over (another model, a clean worktree) never rewrites a record some other window
|
|
183
|
+
// is showing, and every attempt keeps its own transcript, cost and URL. Attempt 1 wears the bare id, so every id minted
|
|
184
|
+
// before attempts existed is attempt 1 of its failure.
|
|
185
|
+
const ATTEMPT_MARK = "-attempt";
|
|
186
|
+
|
|
187
|
+
export const fixAttemptId = (base: string, attempt: number): string => (attempt <= 1 ? base : `${base}${ATTEMPT_MARK}${attempt}`);
|
|
188
|
+
|
|
189
|
+
// Which attempt at `base` this id is, or undefined for an id that is none of them. Read base-relative, never the other
|
|
190
|
+
// way round: a base ends in a run id or a digest, so nothing about an id on its own says where an attempt marker would
|
|
191
|
+
// begin, and `ci-fix-web-41-2` is repo `web-41`'s run 2, not a second go at run 41.
|
|
192
|
+
export const fixAttemptOf = (base: string, id: string): number | undefined => {
|
|
193
|
+
if (id === base) {
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
if (!id.startsWith(`${base}${ATTEMPT_MARK}`)) {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
const rest = id.slice(base.length + ATTEMPT_MARK.length);
|
|
200
|
+
// One spelling per attempt: the first is the bare id, so `-attempt1` and a zero-padded number are nobody's.
|
|
201
|
+
return /^[2-9]\d*$|^[1-9]\d+$/.test(rest) ? Number(rest) : undefined;
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export interface FixAttempt<T extends { readonly id: string }> {
|
|
205
|
+
readonly attempt: number;
|
|
206
|
+
readonly agent: T;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Every attempt at `base` among `agents`, earliest first. The roster is the record: nothing else files which
|
|
210
|
+
// conversations answered a failure, and none is needed, since the ids say so.
|
|
211
|
+
export const fixAttemptsOf = <T extends { readonly id: string }>(base: string, agents: Iterable<T>): FixAttempt<T>[] => {
|
|
212
|
+
const attempts: FixAttempt<T>[] = [];
|
|
213
|
+
for (const agent of agents) {
|
|
214
|
+
const attempt = fixAttemptOf(base, agent.id);
|
|
215
|
+
if (attempt !== undefined) {
|
|
216
|
+
attempts.push({ attempt, agent });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return attempts.toSorted((left, right) => left.attempt - right.attempt);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
// The failure's live answer: its newest attempt, since an older one was set aside by the very act of starting a newer.
|
|
223
|
+
export const latestFixAttempt = <T extends { readonly id: string }>(base: string, agents: Iterable<T>): FixAttempt<T> | undefined =>
|
|
224
|
+
fixAttemptsOf(base, agents).at(-1);
|
|
225
|
+
|
|
226
|
+
// The id the next attempt takes: one past every attempt KNOWN, archived ones included. A start-over files the last
|
|
227
|
+
// attempt away rather than deleting it, and a message to an archived conversation un-archives it, so minting a number
|
|
228
|
+
// the archive holds would quietly resurrect the attempt the reader just set aside.
|
|
229
|
+
export const nextFixAttemptId = (base: string, knownIds: Iterable<string>): string => {
|
|
230
|
+
let highest = 0;
|
|
231
|
+
for (const id of knownIds) {
|
|
232
|
+
highest = Math.max(highest, fixAttemptOf(base, id) ?? 0);
|
|
233
|
+
}
|
|
234
|
+
return fixAttemptId(base, highest + 1);
|
|
235
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -100,6 +100,8 @@ export * from "./state/workspace-state.js";
|
|
|
100
100
|
export * from "./state/runtime-state.js";
|
|
101
101
|
export * from "./state/state-portability.js";
|
|
102
102
|
export * from "./state/history-state.js";
|
|
103
|
+
export * from "./state/fix-stance.js";
|
|
104
|
+
export * from "./state/fix-attempt-plan.js";
|
|
103
105
|
// Provider vocabulary bottom-up: agent-runtimes, provider-specs, agent-catalog; three modules since the dependency
|
|
104
106
|
// points one way.
|
|
105
107
|
export * from "./models/agent-runtimes.js";
|
package/src/schemas/ci.ts
CHANGED
|
@@ -130,6 +130,14 @@ export const CiFixParamSchema = CiRunParamSchema.extend({
|
|
|
130
130
|
pick: AgentRunPickSchema.describe(
|
|
131
131
|
"Which model to open the conversation on, when somebody chose one. Leave it out for the sandbox's own choice, which is the ordinary path.",
|
|
132
132
|
),
|
|
133
|
+
// The verb the picker's bar was ended with, when an attempt already exists (state/fix-attempt-plan.ts). Left out,
|
|
134
|
+
// an ended attempt is continued and a fresh press opens attempt 1; one still in play answers CONFLICT.
|
|
135
|
+
mode: z
|
|
136
|
+
.enum(["continue", "start-over"])
|
|
137
|
+
.optional()
|
|
138
|
+
.describe(
|
|
139
|
+
"What to do about the attempt already made at this run, when there is one. `continue` carries on in that conversation; `start-over` stops it if running, files it away, and opens the next attempt on a clean worktree. Leave it out for the plain press: an attempt that ended is continued, a fresh failure gets attempt 1, and one still in play answers CONFLICT with why.",
|
|
140
|
+
),
|
|
133
141
|
force: z
|
|
134
142
|
.boolean()
|
|
135
143
|
.optional()
|
package/src/schemas/devices.ts
CHANGED
|
@@ -114,17 +114,44 @@ export type DeviceAgentFlowInput = z.infer<typeof DeviceAgentFlowInputSchema>;
|
|
|
114
114
|
// command: the same socket carries `run_command`, which would grant a shell on the user's machine.
|
|
115
115
|
// `sync-unpair` runs the machine's own `sync uninstall --sandbox` (terminates both Mutagen sessions, drops the pairing,
|
|
116
116
|
// self-revokes enrollment); revoking from the sandbox side is a different route.
|
|
117
|
-
|
|
117
|
+
// `dev-reload` and `sync-install` are the two whose argv the daemon fills from what only it knows — the dev checkout on
|
|
118
|
+
// that machine, a freshly minted pairing token — which is also why neither takes a path or a token from the caller.
|
|
119
|
+
export const DeviceCommandSchema = z.enum([
|
|
120
|
+
"mirror-off",
|
|
121
|
+
"mirror-on",
|
|
122
|
+
"sync-pause",
|
|
123
|
+
"sync-resume",
|
|
124
|
+
"sync-unpair",
|
|
125
|
+
"dev-reload",
|
|
126
|
+
"sync-install",
|
|
127
|
+
]);
|
|
118
128
|
export type DeviceCommand = z.infer<typeof DeviceCommandSchema>;
|
|
129
|
+
// The reversible sync switches: the subset a device's own row drives with a pair of buttons, as opposed to the two
|
|
130
|
+
// commands a card elsewhere issues once. Its own type so those button tables stay total without carrying entries for
|
|
131
|
+
// commands they can never send.
|
|
132
|
+
export const DeviceSyncSwitchSchema = DeviceCommandSchema.exclude(["dev-reload", "sync-install"]);
|
|
133
|
+
export type DeviceSyncSwitch = z.infer<typeof DeviceSyncSwitchSchema>;
|
|
119
134
|
// Machine's sandbox id (absent = every paired sandbox); must start alphanumeric, else it parses as a CLI flag.
|
|
120
135
|
export const DeviceSandboxIdSchema = z
|
|
121
136
|
.string()
|
|
122
137
|
.max(200)
|
|
123
138
|
.regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
|
|
139
|
+
// A folder on the DEVICE for `sync-install` to keep in step. The only caller-supplied string that reaches a command
|
|
140
|
+
// line, so its shape is the guard: `~`, an absolute POSIX path or a drive letter, and none of the characters that would
|
|
141
|
+
// end the argument it sits in. `~` is expanded by the daemon, which is why `$` is not allowed to arrive here.
|
|
142
|
+
export const DeviceLocalDirSchema = z
|
|
143
|
+
.string()
|
|
144
|
+
.min(1)
|
|
145
|
+
.max(4096)
|
|
146
|
+
.regex(/^(?:~|\/|[A-Za-z]:[\\/])[^"'`$;|&\n\r]*$/);
|
|
124
147
|
export const DeviceCommandInputSchema = z.object({
|
|
125
148
|
id: z.string().min(1),
|
|
126
149
|
command: DeviceCommandSchema,
|
|
127
150
|
sandboxId: DeviceSandboxIdSchema.optional(),
|
|
151
|
+
// `sync-install` only: which half to enroll (a member's "sync" still comes back mirror, decided daemon-side) and,
|
|
152
|
+
// for file sync, the folder on that device. The pairing token is minted by the daemon; no caller ever carries one.
|
|
153
|
+
mode: z.enum(["sync", "mirror"]).optional(),
|
|
154
|
+
localDir: DeviceLocalDirSchema.optional(),
|
|
128
155
|
});
|
|
129
156
|
export type DeviceCommandInput = z.infer<typeof DeviceCommandInputSchema>;
|
|
130
157
|
// `ok` is the command's own exit status, not this route's: a refusal or non-zero exit is a real answer, not a thrown
|
|
@@ -286,6 +313,16 @@ export const DeviceSchema = z.object({
|
|
|
286
313
|
});
|
|
287
314
|
export type Device = z.infer<typeof DeviceSchema>;
|
|
288
315
|
export const DevicesListSchema = z.object({ devices: z.array(DeviceSchema) });
|
|
316
|
+
|
|
317
|
+
// The connected, online device whose docker reports a given sandbox slug: the machine that sandbox RUNS ON, as opposed
|
|
318
|
+
// to any machine merely paired with it. Answers the question every "run it there instead of asking the owner to type
|
|
319
|
+
// it" path starts from, and is `undefined` for a sync-only agent, which reports no containers at all.
|
|
320
|
+
export const hostRunningSandbox = (devices: readonly Device[], slug: string | undefined): string | undefined =>
|
|
321
|
+
slug === undefined || slug === ""
|
|
322
|
+
? undefined
|
|
323
|
+
: devices.find(
|
|
324
|
+
(device) => device.hostId !== undefined && device.online === true && (device.report?.sandboxes ?? []).some((box) => box.slug === slug),
|
|
325
|
+
)?.hostId;
|
|
289
326
|
// GET /system/sync: sandbox-level facts only, whether sync is possible, whether anything is enrolled, and raw device
|
|
290
327
|
// reports. Cheap: the sidebar badge reads this and must never fan out to a device.
|
|
291
328
|
export const SyncStatusSchema = z.object({
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { ciFixConversationId, fixAttemptId } from "../ids/conversation-ids.js";
|
|
3
|
+
import type { AgentSummary } from "../schemas/agents.js";
|
|
4
|
+
import { planFixAttempt } from "./fix-attempt-plan.js";
|
|
5
|
+
|
|
6
|
+
const NO_ATTENTION = { plan: false, question: false, permission: false, capability: false, credential: false, conflict: false };
|
|
7
|
+
const BASE = ciFixConversationId(`web`, 41);
|
|
8
|
+
|
|
9
|
+
const agent = (id: string, over: Partial<AgentSummary> = {}): AgentSummary => ({
|
|
10
|
+
id,
|
|
11
|
+
status: `running`,
|
|
12
|
+
provider: `claude`,
|
|
13
|
+
harness: `native`,
|
|
14
|
+
attention: { ...NO_ATTENTION },
|
|
15
|
+
updatedAt: 1_000,
|
|
16
|
+
...over,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("with nobody on the failure, a press opens attempt 1 under the failure's own id", () => {
|
|
20
|
+
expect(planFixAttempt(BASE, [], [])).toEqual({ kind: `new`, conversationId: BASE, attempt: 1 });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// The archive counts: attempt 1 was set aside earlier, and re-minting its id would un-archive it.
|
|
24
|
+
test("an archived attempt is skipped over, not resurrected", () => {
|
|
25
|
+
expect(planFixAttempt(BASE, [], [BASE])).toEqual({ kind: `new`, conversationId: fixAttemptId(BASE, 2), attempt: 2 });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a landed attempt is closed: the same gates red again get a fresh attempt", () => {
|
|
29
|
+
const plan = planFixAttempt(BASE, [agent(BASE, { status: `landed` })], []);
|
|
30
|
+
expect(plan).toEqual({ kind: `new`, conversationId: fixAttemptId(BASE, 2), attempt: 2 });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("an attempt that ended is continued by a plain press, and by an explicit Continue", () => {
|
|
34
|
+
const roster = [agent(BASE, { status: `error`, failure: `no capacity` })];
|
|
35
|
+
expect(planFixAttempt(BASE, roster, [])).toEqual({ kind: `continue`, conversationId: BASE, attempt: 1 });
|
|
36
|
+
expect(planFixAttempt(BASE, roster, [], `continue`)).toEqual({ kind: `continue`, conversationId: BASE, attempt: 1 });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("start over retires the latest attempt and numbers the next past it", () => {
|
|
40
|
+
const second = fixAttemptId(BASE, 2);
|
|
41
|
+
const plan = planFixAttempt(BASE, [agent(BASE, { status: `stopped` }), agent(second, { status: `error` })], [], `start-over`);
|
|
42
|
+
expect(plan).toEqual({ kind: `start-over`, retire: second, stopFirst: false, conversationId: fixAttemptId(BASE, 3), attempt: 3 });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Only a running turn needs stopping before the archive will take it; a parked one is already still.
|
|
46
|
+
test("starting over on a working attempt stops it first", () => {
|
|
47
|
+
const plan = planFixAttempt(BASE, [agent(BASE, { status: `running` })], [], `start-over`);
|
|
48
|
+
expect(plan).toMatchObject({ kind: `start-over`, retire: BASE, stopFirst: true });
|
|
49
|
+
const parked = planFixAttempt(BASE, [agent(BASE, { status: `awaiting`, attention: { ...NO_ATTENTION, question: true } })], [], `start-over`);
|
|
50
|
+
expect(parked).toMatchObject({ kind: `start-over`, stopFirst: false });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// A second agent beside a live one is the race the derived id exists to prevent; the press opens the live one instead.
|
|
54
|
+
test.each([`running`, `awaiting`, `ready`] as const)("an attempt still in play (%s) is busy, not a fresh press", (status) => {
|
|
55
|
+
const plan = planFixAttempt(BASE, [agent(BASE, { status })], []);
|
|
56
|
+
expect(plan).toMatchObject({ kind: `busy`, conversationId: BASE });
|
|
57
|
+
// Continue is not a way around it either.
|
|
58
|
+
expect(planFixAttempt(BASE, [agent(BASE, { status })], [], `continue`).kind).toBe(`busy`);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a spent allowance is a wait, not an ending: the press does not spend it again", () => {
|
|
62
|
+
const plan = planFixAttempt(BASE, [agent(BASE, { status: `error`, failureCode: `rate_limit` })], []);
|
|
63
|
+
expect(plan.kind).toBe(`busy`);
|
|
64
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { fixAttemptOf, latestFixAttempt, nextFixAttemptId } from "../ids/conversation-ids.js";
|
|
2
|
+
import type { AgentSummary } from "../schemas/agents.js";
|
|
3
|
+
import { type FixStance, fixStance } from "./fix-stance.js";
|
|
4
|
+
|
|
5
|
+
/* WHAT A PRESS ON A FAILURE'S FIX CONTROL DOES, decided once for every surface that has one — the push question in
|
|
6
|
+
* the shell, the pipelines board's rows, the daemon's own /ci/fix route — so the browser and the daemon cannot
|
|
7
|
+
* disagree about whether a press continues an attempt, opens the next one, or has to wait. Pure: the roster in, a
|
|
8
|
+
* plan out. Carrying the plan out (stopping, archiving, starting) is the caller's, since each side does that
|
|
9
|
+
* through its own doors.
|
|
10
|
+
*
|
|
11
|
+
* THE RULE, in the order it is read: nothing live (or the last answer landed) opens the NEXT attempt; "start over"
|
|
12
|
+
* sets the latest aside and opens the next; an attempt that ENDED is continued; anything else is still in play and
|
|
13
|
+
* the reader is sent to it rather than allowed to race a second agent beside it. */
|
|
14
|
+
|
|
15
|
+
// How the reader ended the picker, when they did; absent is the plain press on the button.
|
|
16
|
+
export type FixResume = "continue" | "start-over";
|
|
17
|
+
|
|
18
|
+
export type FixAttemptPlan =
|
|
19
|
+
// Nothing live answers this failure, or the last answer landed: open the next attempt with the full prompt.
|
|
20
|
+
| { readonly kind: "new"; readonly conversationId: string; readonly attempt: number }
|
|
21
|
+
// The latest attempt ended without a fix: carry on in it, with a nudge rather than the whole opening prompt.
|
|
22
|
+
| { readonly kind: "continue"; readonly conversationId: string; readonly attempt: number }
|
|
23
|
+
// Set the latest attempt aside (stopped first if still running) and open the next one on a clean worktree.
|
|
24
|
+
| {
|
|
25
|
+
readonly kind: "start-over";
|
|
26
|
+
readonly retire: string;
|
|
27
|
+
readonly stopFirst: boolean;
|
|
28
|
+
readonly conversationId: string;
|
|
29
|
+
readonly attempt: number;
|
|
30
|
+
}
|
|
31
|
+
// The latest attempt is still in play: working, parked on the reader, or holding a fix to review. Open it instead.
|
|
32
|
+
| { readonly kind: "busy"; readonly conversationId: string; readonly stance: FixStance };
|
|
33
|
+
|
|
34
|
+
// `roster` is the live fleet; `knownIds` is every conversation id that exists, archived ones included, since the next
|
|
35
|
+
// attempt's number must clear the archive too (conversation-ids.ts, nextFixAttemptId).
|
|
36
|
+
export const planFixAttempt = (base: string, roster: readonly AgentSummary[], knownIds: Iterable<string>, resume?: FixResume): FixAttemptPlan => {
|
|
37
|
+
const latest = latestFixAttempt(base, roster);
|
|
38
|
+
const stance = latest === undefined ? undefined : fixStance(latest.agent);
|
|
39
|
+
const known = [...knownIds, ...(latest === undefined ? [] : [latest.agent.id])];
|
|
40
|
+
const next = (): { conversationId: string; attempt: number } => {
|
|
41
|
+
const conversationId = nextFixAttemptId(base, known);
|
|
42
|
+
return { conversationId, attempt: fixAttemptOf(base, conversationId) ?? 1 };
|
|
43
|
+
};
|
|
44
|
+
if (latest === undefined || stance === undefined || stance.kind === "landed") {
|
|
45
|
+
return { kind: "new", ...next() };
|
|
46
|
+
}
|
|
47
|
+
if (resume === "start-over") {
|
|
48
|
+
return { kind: "start-over", retire: latest.agent.id, stopFirst: stance.kind === "working", ...next() };
|
|
49
|
+
}
|
|
50
|
+
if (stance.retry) {
|
|
51
|
+
return { kind: "continue", conversationId: latest.agent.id, attempt: latest.attempt };
|
|
52
|
+
}
|
|
53
|
+
return { kind: "busy", conversationId: latest.agent.id, stance };
|
|
54
|
+
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { fixStance } from "./fix-stance.js";
|
|
3
|
+
import type { AgentSummary } from "../schemas/agents.js";
|
|
4
|
+
|
|
5
|
+
const NO_ATTENTION = { plan: false, question: false, permission: false, capability: false, credential: false, conflict: false };
|
|
6
|
+
|
|
7
|
+
const agent = (over: Partial<AgentSummary> = {}): AgentSummary => ({
|
|
8
|
+
id: `ci-fix-web-42`,
|
|
9
|
+
status: `running`,
|
|
10
|
+
provider: `claude`,
|
|
11
|
+
harness: `native`,
|
|
12
|
+
attention: { ...NO_ATTENTION },
|
|
13
|
+
updatedAt: 1_000,
|
|
14
|
+
...over,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("a live turn reads as work in progress, and asks nothing of the reader", () => {
|
|
18
|
+
const stance = fixStance(agent({ status: `running` }));
|
|
19
|
+
expect(stance.kind).toBe(`working`);
|
|
20
|
+
expect(stance.ongoing).toBe(true);
|
|
21
|
+
expect(stance.retry).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test.each([`resuming`, `stopping`, `dismissing`] as const)("%s still reads as working", (status) => {
|
|
25
|
+
expect(fixStance(agent({ status })).kind).toBe(`working`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a parked turn is read off the attention flag, not the status", () => {
|
|
29
|
+
const stance = fixStance(agent({ status: `idle`, attention: { ...NO_ATTENTION, question: true } }));
|
|
30
|
+
expect(stance.kind).toBe(`needs-you`);
|
|
31
|
+
expect(stance.label).toBe(`Question for you`);
|
|
32
|
+
expect(stance.ongoing).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a setup that is not connected yet outranks a question raised beside it", () => {
|
|
36
|
+
const stance = fixStance(agent({ status: `awaiting`, attention: { ...NO_ATTENTION, question: true, capability: true } }));
|
|
37
|
+
expect(stance.label).toBe(`Setup needed`);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a turn parked with no flag yet still says it is waiting on you", () => {
|
|
41
|
+
expect(fixStance(agent({ status: `awaiting` })).kind).toBe(`needs-you`);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("held work reads as a fix to review, whether the fleet called it ready or left it idle", () => {
|
|
45
|
+
expect(fixStance(agent({ status: `ready` })).kind).toBe(`ready`);
|
|
46
|
+
expect(fixStance(agent({ status: `idle`, diff: { files: 4, insertions: 120, deletions: 8 } })).kind).toBe(`ready`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Diff present doesn't change a failed status to ready; it only earns a mention in the hint.
|
|
50
|
+
test("an agent that failed after writing files still reads as failed", () => {
|
|
51
|
+
const stance = fixStance(agent({ status: `error`, diff: { files: 2, insertions: 34, deletions: 6 } }));
|
|
52
|
+
expect(stance.kind).toBe(`ended`);
|
|
53
|
+
expect(stance.retry).toBe(true);
|
|
54
|
+
expect(stance.hint).toContain(`2 changed files on its branch`);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("a finished turn that changed nothing offers to try again", () => {
|
|
58
|
+
const stance = fixStance(agent({ status: `idle` }));
|
|
59
|
+
expect(stance.kind).toBe(`ended`);
|
|
60
|
+
expect(stance.label).toBe(`Nothing changed`);
|
|
61
|
+
expect(stance.retry).toBe(true);
|
|
62
|
+
expect(stance.ongoing).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a landed fix is over, and stops holding the branch's other rows back", () => {
|
|
66
|
+
const stance = fixStance(agent({ status: `landed`, diff: { files: 2, insertions: 10, deletions: 1 } }));
|
|
67
|
+
expect(stance.kind).toBe(`landed`);
|
|
68
|
+
expect(stance.ongoing).toBe(false);
|
|
69
|
+
expect(stance.retry).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a failed turn carries the provider's own sentence into the hint", () => {
|
|
73
|
+
const stance = fixStance(agent({ status: `error`, failure: `the model endpoint refused the request` }));
|
|
74
|
+
expect(stance.kind).toBe(`ended`);
|
|
75
|
+
expect(stance.retry).toBe(true);
|
|
76
|
+
expect(stance.hint).toContain(`the model endpoint refused the request`);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Retry is false because retrying would spend the same already-refused allowance.
|
|
80
|
+
test("a spent allowance reads as a wait rather than an error", () => {
|
|
81
|
+
const stance = fixStance(agent({ status: `error`, failureCode: `rate_limit`, failure: `usage limit reached` }));
|
|
82
|
+
expect(stance.kind).toBe(`waiting`);
|
|
83
|
+
expect(stance.retry).toBe(false);
|
|
84
|
+
expect(stance.ongoing).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a stopped turn says so, and offers the press again", () => {
|
|
88
|
+
const stance = fixStance(agent({ status: `stopped` }));
|
|
89
|
+
expect(stance.label).toBe(`Stopped`);
|
|
90
|
+
expect(stance.retry).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// An unknown status can arrive from a newer daemon; the stance must still read rather than crash on a missing ending.
|
|
94
|
+
test("an unknown ending still produces a stance", () => {
|
|
95
|
+
const stance = fixStance(agent({ status: `teleported` as AgentSummary["status"] }));
|
|
96
|
+
expect(stance.kind).toBe(`ended`);
|
|
97
|
+
expect(stance.label).toBe(`Agent stopped`);
|
|
98
|
+
expect(stance.retry).toBe(true);
|
|
99
|
+
});
|