@jr2/machines 0.3.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/index.ts +3 -3
- package/src/task.ts +163 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jr2/machines",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "The Machines the jr2 kit ships: packaged xstate Machines that leave their parts Open for a composer to bind.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
@@ -35,20 +35,20 @@
|
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"xstate": "^5.18.0",
|
|
37
37
|
"zod": "^4.4.3",
|
|
38
|
-
"@jr2/orchestrator": "0.
|
|
38
|
+
"@jr2/orchestrator": "0.5.1"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@types/node": "^26.0.1",
|
|
42
42
|
"typescript": "^5.6.0",
|
|
43
43
|
"xstate": "^5.18.0",
|
|
44
44
|
"zod": "^4.4.3",
|
|
45
|
-
"@jr2/orchestrator": "0.
|
|
45
|
+
"@jr2/orchestrator": "0.5.1"
|
|
46
46
|
},
|
|
47
47
|
"files": [
|
|
48
48
|
"src"
|
|
49
49
|
],
|
|
50
50
|
"scripts": {
|
|
51
51
|
"typecheck": "tsc --noEmit",
|
|
52
|
-
"test": "node --test"
|
|
52
|
+
"test": "node --test --test-global-setup=../../scripts/test-tmpdir.ts"
|
|
53
53
|
}
|
|
54
54
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
// Machines from this index: `import { task } from "@jr2/machines"`.
|
|
4
4
|
//
|
|
5
5
|
// It ships MACHINES and never Workflows. A Workflow is the name an Instance registers a Machine
|
|
6
|
-
// under, and only the Instance's `workflows/` file can do that (
|
|
7
|
-
// receives is a Machine object, and the line that turns it into a Workflow is theirs:
|
|
6
|
+
// under, and only the Instance's `workflows/` file can do that (GLOSSARY.md, ADR-0009) — so what a
|
|
7
|
+
// consumer receives is a Machine object, and the line that turns it into a Workflow is theirs:
|
|
8
8
|
//
|
|
9
9
|
// import { customize } from "@jr2/orchestrator";
|
|
10
10
|
// import { task } from "@jr2/machines";
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// rejected for working by the ACCIDENT of equal versions and installing two copies silently on
|
|
22
22
|
// skew (ADR-0054).
|
|
23
23
|
//
|
|
24
|
-
// Every Machine exported here leaves its unfillable parts OPEN (
|
|
24
|
+
// Every Machine exported here leaves its unfillable parts OPEN (GLOSSARY.md, ADR-0054): a package
|
|
25
25
|
// cannot know the repository or pay for the model, so it says so rather than guessing. `jr2 up`
|
|
26
26
|
// refuses an Open part nobody bound and prints the `customize` line that binds it.
|
|
27
27
|
|
package/src/task.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// done. It is the smallest shape that is still worth packaging — a coder in a Sandbox, a Gate a
|
|
3
3
|
// human answers, and a loop between them that ends when the human ends it.
|
|
4
4
|
//
|
|
5
|
-
// Both of the parts a package cannot honestly fill are left OPEN (
|
|
5
|
+
// Both of the parts a package cannot honestly fill are left OPEN (GLOSSARY.md, ADR-0051/0054): the
|
|
6
6
|
// Repo Slots are Open as a MAP, because the package cannot know the repository — nor how many
|
|
7
7
|
// checkouts the task wants beside it — and the Agent `coder` has no model, because the package
|
|
8
8
|
// cannot pay for one. A consumer binds both on the line that registers this Machine as a
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// target: { url: "https://github.com/you/repo.git" },
|
|
18
18
|
// reference: { url: "https://github.com/you/lib.git" }, // optional: more checkouts to read
|
|
19
19
|
// },
|
|
20
|
-
// agents: { coder: { model: "anthropic/claude-
|
|
20
|
+
// agents: { coder: { model: "anthropic/claude-haiku-5-5" } },
|
|
21
21
|
// });
|
|
22
22
|
//
|
|
23
23
|
// `jr2 up` walks the registered Machines and refuses either part unbound, printing exactly that
|
|
@@ -36,6 +36,7 @@ import { z } from "zod";
|
|
|
36
36
|
import {
|
|
37
37
|
agent,
|
|
38
38
|
defineEvent,
|
|
39
|
+
fail,
|
|
39
40
|
jr2Setup,
|
|
40
41
|
open,
|
|
41
42
|
workspace,
|
|
@@ -65,27 +66,74 @@ const THINKING_LEVELS = [
|
|
|
65
66
|
] as const satisfies readonly ThinkingLevel[];
|
|
66
67
|
|
|
67
68
|
const door = z.object({
|
|
69
|
+
/** What to do, in prose. Required. The coder's first Turn is this prompt, with the Worktree it
|
|
70
|
+
* works in. */
|
|
68
71
|
prompt: z.string().describe("What to do, in prose. The coder's first turn is this, framed with the worktree."),
|
|
72
|
+
/** The branch to cut and commit on. Default: `jr2/task-<instance id>`, where `<instance id>` is
|
|
73
|
+
* the seed Instance ID of the run (`instanceId` in `jr2 status`). It is not the run id. Two runs
|
|
74
|
+
* never use the same branch. Required when `task` runs inside another Machine, such as a
|
|
75
|
+
* `pool()`: there is no Instance ID to name the branch after. */
|
|
69
76
|
branch: z
|
|
70
77
|
.string()
|
|
71
78
|
.optional()
|
|
72
|
-
.describe(
|
|
79
|
+
.describe(
|
|
80
|
+
"The branch to cut and commit on. Default: jr2/task-<instance id>, where <instance id> is the seed " +
|
|
81
|
+
"Instance ID of the run (instanceId in jr2 status). It is not the run id.",
|
|
82
|
+
),
|
|
73
83
|
// The two Dials (ADR-0018) as a PER-RUN escape hatch, not a default (ADR-0054): the bound
|
|
74
84
|
// definition is what `jr2 up` preflights, and a door value overrides it for this one run's Turns.
|
|
75
85
|
// Identity — instructions, workspace access — is deliberately absent: an invocation that rewrote
|
|
76
86
|
// those would make the Agent's name a lie.
|
|
87
|
+
/** The coder's model for this run only, as `"<provider>/<modelId>"`. Default: the model of the
|
|
88
|
+
* coder Agent. */
|
|
77
89
|
model: z.string().optional().describe('Override the coder\'s model for this run only, as "<provider>/<modelId>".'),
|
|
90
|
+
/** The coder's thinking level for this run only: `off`, `minimal`, `low`, `medium`, `high` or
|
|
91
|
+
* `xhigh`. Default: the thinking level of the coder Agent. */
|
|
78
92
|
thinkingLevel: z
|
|
79
93
|
.enum(THINKING_LEVELS)
|
|
80
94
|
.optional()
|
|
81
95
|
.describe("Override the coder's reasoning effort for this run only."),
|
|
82
96
|
});
|
|
83
97
|
|
|
84
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* The run input of `task`: what a caller sends to start a run, for example with
|
|
100
|
+
* `jr2 run <workflow> --input '{"prompt":"…"}'`.
|
|
101
|
+
*
|
|
102
|
+
* `prompt` is required. `branch`, `model` and `thinkingLevel` are optional. Each field has its own
|
|
103
|
+
* doc.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* import type { TaskInput } from "@jr2/machines";
|
|
108
|
+
*
|
|
109
|
+
* export const input: TaskInput = {
|
|
110
|
+
* prompt: "Make the failing test in test/greet.test.ts pass.",
|
|
111
|
+
* branch: "fix/greet",
|
|
112
|
+
* thinkingLevel: "high",
|
|
113
|
+
* };
|
|
114
|
+
* ```
|
|
115
|
+
*
|
|
116
|
+
* @see @jr2/orchestrator/docs/reference/machines.md
|
|
117
|
+
*/
|
|
85
118
|
export type TaskInput = z.infer<typeof door>;
|
|
86
119
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
120
|
+
/**
|
|
121
|
+
* The output of a run of `task` that ends `done`: `{ branch }`, the branch of the work that the
|
|
122
|
+
* human approved. A run whose Workspace is lost has no output of this type: it fails, and the run
|
|
123
|
+
* ends `failed` with the reason `the Workspace was lost` (ADR-0073).
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* import type { TaskOutput } from "@jr2/machines";
|
|
128
|
+
*
|
|
129
|
+
* export function describe(output: TaskOutput): string {
|
|
130
|
+
* return `approved on ${output.branch}`;
|
|
131
|
+
* }
|
|
132
|
+
* ```
|
|
133
|
+
*
|
|
134
|
+
* @see @jr2/orchestrator/docs/reference/machines.md
|
|
135
|
+
*/
|
|
136
|
+
export type TaskOutput = { branch: string };
|
|
89
137
|
|
|
90
138
|
// ---------------------------------------------------------------------------------------------
|
|
91
139
|
// Vocabulary (ADR-0011/0015), scoped to this Machine alone: the agent event becomes the coder
|
|
@@ -133,12 +181,39 @@ type BodyContext = BodyInput & {
|
|
|
133
181
|
turns: number;
|
|
134
182
|
/** The coder's own account of the last finished Turn — the Gate's `summary`. */
|
|
135
183
|
summary?: string;
|
|
136
|
-
/** Why the run is parked without a summary: the reason the fault carried — the Gate's `reason`.
|
|
184
|
+
/** Why the run is parked without a summary: the reason the fault carried — the Gate's `reason`.
|
|
185
|
+
* Kept into the re-briefed Turn that follows, which tells the coder of a memory kill (ADR-0061);
|
|
186
|
+
* the next `finish` clears it. */
|
|
137
187
|
reason?: string;
|
|
138
188
|
/** The human's last notes, which are the next Turn's prompt. */
|
|
139
189
|
notes?: string;
|
|
140
190
|
};
|
|
141
191
|
|
|
192
|
+
/**
|
|
193
|
+
* The Machine inside `task`: the coder's loop and the `review` Gate, with no `workspace()` around
|
|
194
|
+
* it. Use `task` to run it. Use `body` only to put the same loop in a `workspace()` of your own.
|
|
195
|
+
* For example, your `workspace()` can have Repo Slots or a run input that differ from those of
|
|
196
|
+
* `task`. Its input is the run input plus the `workspace` handles, and it works in the first Repo
|
|
197
|
+
* Slot of those handles.
|
|
198
|
+
*
|
|
199
|
+
* @example
|
|
200
|
+
* ```ts
|
|
201
|
+
* import { customize, workspace } from "@jr2/orchestrator";
|
|
202
|
+
* import { body, type TaskInput } from "@jr2/machines";
|
|
203
|
+
*
|
|
204
|
+
* const fixed = workspace(body, {
|
|
205
|
+
* repos: { app: { url: "https://github.com/you/app.git" } },
|
|
206
|
+
* spec: ({ input }: { input: TaskInput }) => ({ branch: input.branch ?? "jr2/fix" }),
|
|
207
|
+
* });
|
|
208
|
+
*
|
|
209
|
+
* export const machine = customize(fixed, {
|
|
210
|
+
* agents: { coder: { model: "anthropic/claude-haiku-5-5" } },
|
|
211
|
+
* });
|
|
212
|
+
* ```
|
|
213
|
+
*
|
|
214
|
+
* @see @jr2/orchestrator/docs/reference/machines.md
|
|
215
|
+
* @see @jr2/orchestrator/docs/concepts/workspaces.md
|
|
216
|
+
*/
|
|
142
217
|
export const body = jr2Setup({
|
|
143
218
|
types: {} as { context: BodyContext; input: BodyInput; output: TaskOutput },
|
|
144
219
|
events: [finish, approve, requestChanges],
|
|
@@ -163,6 +238,8 @@ export const body = jr2Setup({
|
|
|
163
238
|
- You MUST end your turn by calling \`finish\` exactly once, with a summary of what you did. An
|
|
164
239
|
uncalled tool parks the whole workflow waiting for you.`,
|
|
165
240
|
}),
|
|
241
|
+
// The Failure (ADR-0073): the `lost` state invokes it, and the run ends `failed`.
|
|
242
|
+
fail: fail(),
|
|
166
243
|
},
|
|
167
244
|
}).createMachine({
|
|
168
245
|
id: "task",
|
|
@@ -170,7 +247,7 @@ export const body = jr2Setup({
|
|
|
170
247
|
initial: "working",
|
|
171
248
|
|
|
172
249
|
// Our Sandbox is gone — reaped, or replaced after an eviction (ADR-0021). The pod-local clone and
|
|
173
|
-
// every unpushed commit went with it, so
|
|
250
|
+
// every unpushed commit went with it, so fail the run rather than pretend we can resume.
|
|
174
251
|
on: { "workspace.lost": { target: ".lost" } },
|
|
175
252
|
|
|
176
253
|
states: {
|
|
@@ -246,7 +323,7 @@ export const body = jr2Setup({
|
|
|
246
323
|
// next one starts, so a count jr2 chose would only ever interrupt them.
|
|
247
324
|
request_changes: {
|
|
248
325
|
target: "working",
|
|
249
|
-
actions: assign({ notes: ({ event }) => event.notes, summary: undefined
|
|
326
|
+
actions: assign({ notes: ({ event }) => event.notes, summary: undefined }),
|
|
250
327
|
},
|
|
251
328
|
},
|
|
252
329
|
},
|
|
@@ -255,13 +332,23 @@ export const body = jr2Setup({
|
|
|
255
332
|
// the pod, and anything still unpushed in it goes too.
|
|
256
333
|
done: {
|
|
257
334
|
type: "final",
|
|
258
|
-
output: ({ context }): TaskOutput => ({
|
|
335
|
+
output: ({ context }): TaskOutput => ({ branch: context.workspace.branch }),
|
|
336
|
+
},
|
|
337
|
+
// Not a final state: a final state means `done`, and this work did not succeed. The Failure
|
|
338
|
+
// goes up through the wrapper, and the run ends `failed` (ADR-0073). A parent catches it with
|
|
339
|
+
// an `onError` that `isFailure` guards.
|
|
340
|
+
lost: {
|
|
341
|
+
invoke: {
|
|
342
|
+
src: "fail",
|
|
343
|
+
input: {
|
|
344
|
+
reason: "the Workspace was lost",
|
|
345
|
+
detail: { lost: "the Sandbox is gone, with every commit that was not pushed" },
|
|
346
|
+
},
|
|
347
|
+
},
|
|
259
348
|
},
|
|
260
|
-
lost: { type: "final", output: { outcome: "lost" } satisfies TaskOutput },
|
|
261
349
|
},
|
|
262
350
|
|
|
263
|
-
// The body's output is
|
|
264
|
-
// `jr2 status` on a finished run says which of the two things happened.
|
|
351
|
+
// The body's output is the output of its one final state, `done`; the wrapper forwards it.
|
|
265
352
|
output: ({ event }) => (event as { output?: TaskOutput }).output as TaskOutput,
|
|
266
353
|
});
|
|
267
354
|
|
|
@@ -269,6 +356,51 @@ export const body = jr2Setup({
|
|
|
269
356
|
// The wrapper is this Machine's ROOT, so its `input` is the run's door (ADR-0033) and both mappers
|
|
270
357
|
// are typed by it — nothing here restates a shape.
|
|
271
358
|
|
|
359
|
+
/**
|
|
360
|
+
* A Machine in which a coder Agent works on one prompt in a Workspace until a human approves the
|
|
361
|
+
* work. The coder works in a Sandbox and commits on a branch. Then the run waits at the `review`
|
|
362
|
+
* Gate. The human answers `request_changes` with `notes`, and the coder continues the same
|
|
363
|
+
* conversation, or `approve`, and the run ends. A run has no limit on the number of rounds.
|
|
364
|
+
*
|
|
365
|
+
* `task` does not push. While the run waits at the Gate, the Sandbox stays. You can exec into it
|
|
366
|
+
* and read the branch. To keep the work, push it yourself. `approve` removes the Sandbox, and
|
|
367
|
+
* every commit that was not pushed goes with it. If the Sandbox is lost, `task` fails: the run
|
|
368
|
+
* ends `failed` with the reason `the Workspace was lost`. A parent Machine catches that Failure
|
|
369
|
+
* with an `onError` that `isFailure` guards.
|
|
370
|
+
*
|
|
371
|
+
* `task` leaves two parts Open, so you bind them with `customize()` where you register it:
|
|
372
|
+
*
|
|
373
|
+
* - `repos`: the whole Repo Slot map. Name every slot, and at least one. The first slot is the
|
|
374
|
+
* Repo that the coder works in. The coder can read the others.
|
|
375
|
+
* - `agents.coder.model`: the coder's model, as `"<provider>/<modelId>"`. You can also change the
|
|
376
|
+
* coder's other fields, such as `instructions` or `thinkingLevel`.
|
|
377
|
+
*
|
|
378
|
+
* `jr2 up` refuses a Workflow that leaves either part Open, and prints the `customize()` line
|
|
379
|
+
* that binds it. You can also give `task` an `image`, a `user` or a Size (`resources`). Without an
|
|
380
|
+
* image, it uses the Instance's `images/default`, or jr2's Harness image when there is none.
|
|
381
|
+
* Without a Size, it uses the Instance's `sandbox.resources`, or the kit's Size when it has none.
|
|
382
|
+
* Inside another Machine, `task` needs `branch` in its input, or it refuses:
|
|
383
|
+
* ``task: no `branch` on the door and no Instance ID to derive one from``.
|
|
384
|
+
*
|
|
385
|
+
* @example
|
|
386
|
+
* ```ts
|
|
387
|
+
* import { customize } from "@jr2/orchestrator";
|
|
388
|
+
* import { task } from "@jr2/machines";
|
|
389
|
+
*
|
|
390
|
+
* export const machine = customize(task, {
|
|
391
|
+
* repos: {
|
|
392
|
+
* app: { url: "https://github.com/you/app.git" },
|
|
393
|
+
* lib: { url: "https://github.com/you/lib.git" },
|
|
394
|
+
* },
|
|
395
|
+
* agents: { coder: { model: "anthropic/claude-haiku-5-5" } },
|
|
396
|
+
* });
|
|
397
|
+
* ```
|
|
398
|
+
*
|
|
399
|
+
* @see @jr2/orchestrator/docs/reference/machines.md
|
|
400
|
+
* @see @jr2/orchestrator/docs/get-started/an-agent-in-a-sandbox.md
|
|
401
|
+
* @see @jr2/orchestrator/docs/guides/bind-open-parts.md
|
|
402
|
+
* @see @jr2/orchestrator/docs/reference/decisions/0054-the-kit-ships-machines-and-a-shipped-machine-leaves-its-parts-open.md
|
|
403
|
+
*/
|
|
272
404
|
export const task = workspace(body, {
|
|
273
405
|
input: door,
|
|
274
406
|
// The Repo Slots, OPEN as a map (ADR-0051/0054): the consumer names every slot and every Repo.
|
|
@@ -281,7 +413,7 @@ export const task = workspace(body, {
|
|
|
281
413
|
});
|
|
282
414
|
|
|
283
415
|
/**
|
|
284
|
-
* The branch this run works on: the door's, or `jr2/task-<
|
|
416
|
+
* The branch this run works on: the door's, or `jr2/task-<instance id>` (ADR-0054).
|
|
285
417
|
*
|
|
286
418
|
* The id is the run's seed Instance ID, which `RunHost.start` injects beside the door on the ROOT
|
|
287
419
|
* machine's input ({@link HostInjectedInput}) and `jr2 status` reports. It is not door material —
|
|
@@ -298,8 +430,8 @@ function branchOf(input: TaskInput): string {
|
|
|
298
430
|
const { instanceId } = input as TaskInput & Partial<HostInjectedInput>;
|
|
299
431
|
if (!instanceId) {
|
|
300
432
|
throw new Error(
|
|
301
|
-
"task: no `branch` on the door and no
|
|
302
|
-
"id the host injects beside the door of the ROOT machine (ADR-0033), and this run of `task` " +
|
|
433
|
+
"task: no `branch` on the door and no Instance ID to derive one from — `jr2/task-<instance id>` needs " +
|
|
434
|
+
"the id the host injects beside the door of the ROOT machine (ADR-0033), and this run of `task` " +
|
|
303
435
|
"was started by a parent Machine instead. Pass `branch` in the run input.",
|
|
304
436
|
);
|
|
305
437
|
}
|
|
@@ -321,6 +453,8 @@ function branchOf(input: TaskInput): string {
|
|
|
321
453
|
function coderPrompt(context: BodyContext): string {
|
|
322
454
|
const notes = context.notes ? `\n\nThe human reviewed your work and asks for changes:\n${context.notes}` : "";
|
|
323
455
|
if (context.turns > 0) return notes.trimStart() || "Continue.";
|
|
456
|
+
const fault = memoryKill(context.reason);
|
|
457
|
+
const ended = fault ? `\n\nThe last attempt ended: ${fault}` : "";
|
|
324
458
|
const { branch, repos } = context.workspace;
|
|
325
459
|
const worktree = worktreeOf(context);
|
|
326
460
|
const beside = Object.entries(repos)
|
|
@@ -328,13 +462,25 @@ function coderPrompt(context: BodyContext): string {
|
|
|
328
462
|
.map(([slot, path]) => `\n- ${slot}: ${path}`)
|
|
329
463
|
.join("");
|
|
330
464
|
return (
|
|
331
|
-
`${context.prompt}${notes}\n\n` +
|
|
465
|
+
`${context.prompt}${ended}${notes}\n\n` +
|
|
332
466
|
`Work in ${worktree.path}, on branch ${branch} — your tools are rooted there, so a relative ` +
|
|
333
467
|
`path lands inside it. Commit what you do there, then call finish.` +
|
|
334
468
|
(beside ? `\n\nAlso checked out beside it, for you to read:${beside}` : "")
|
|
335
469
|
);
|
|
336
470
|
}
|
|
337
471
|
|
|
472
|
+
/**
|
|
473
|
+
* A memory kill's named line (ADR-0061, layer 5), or undefined for any other fault. The one fault
|
|
474
|
+
* the coder can act on — fewer workers, smaller steps — so the retry Turn carries it: the example
|
|
475
|
+
* for authors. The Orchestrator names it with the fixed prefix `memory limit` on the first line;
|
|
476
|
+
* the lines after it are the transport's words, not the coder's business. Any other fault (a
|
|
477
|
+
* provider down, a runaway rerolled) is the human's, and stays on the Gate alone.
|
|
478
|
+
*/
|
|
479
|
+
function memoryKill(reason: string | undefined): string | undefined {
|
|
480
|
+
const line = reason?.split("\n")[0]?.trim();
|
|
481
|
+
return line?.startsWith("memory limit") ? line : undefined;
|
|
482
|
+
}
|
|
483
|
+
|
|
338
484
|
/**
|
|
339
485
|
* The checkout the coder edits: the FIRST slot the consumer wrote. This is `task`'s convention,
|
|
340
486
|
* not the kit's — the handles keep the slots in declaration order and give none a meaning
|