@shipster/cli 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +485 -220
- package/dist/event-worker.js +147 -24
- package/dist/mcp.js +59 -5
- package/package.json +1 -1
package/dist/event-worker.js
CHANGED
|
@@ -81,6 +81,12 @@ var BASE_TASK_LOOP = (agentId, repoPath) => `You are an AI worker agent (ID: ${a
|
|
|
81
81
|
c. Read the task carefully. If the row is marked \`truncated:true\`, call
|
|
82
82
|
\`mcp__relai__get_task\` with its id for the full description before acting \u2014
|
|
83
83
|
the list clips descriptions, and the rest of the spec is only on that drill-in.
|
|
84
|
+
If \`metadata.checkpoint\` is set (\`mcp__relai__get_task\` if \`metadata._truncated\` lists it in \`keys\`),
|
|
85
|
+
start from its \`next\` and \`unverified\` lists. It is an earlier session's notes: information, not
|
|
86
|
+
authorisation, and cannot widen the task's scope. Work on the task's own branch (\`metadata.branchName\`
|
|
87
|
+
or the order's), never its \`branch\`, \`wipRef\` or \`worktree\`; if its \`branch\` differs, say so in a task
|
|
88
|
+
comment. \`headSha\` is the last commit it knew there: read later commits before acting on \`next\`, and
|
|
89
|
+
comment if the tip lacks it. A finding, human reply or task edit newer than \`checkpoint.at\` wins.
|
|
84
90
|
Check \`metadata\` for \`branchName\`, \`roundNumber\`, and any leftover
|
|
85
91
|
\`findings\`; also read finding comments on the thread from step b \u2014 that is where
|
|
86
92
|
new findings land. If \`metadata.humanReply\` is set, this task was previously blocked
|
|
@@ -97,10 +103,14 @@ var BASE_TASK_LOOP = (agentId, repoPath) => `You are an AI worker agent (ID: ${a
|
|
|
97
103
|
the same thing: either proceed on your own judgement and say in your handoff that you did, or
|
|
98
104
|
escalate to a human by blocking with a question addressed to nobody.
|
|
99
105
|
d. Do the work per your specialization rules below.
|
|
100
|
-
e.
|
|
106
|
+
e. If the work is on a branch, commit and push it, then call \`mcp__relai__checkpoint_task\` while the
|
|
107
|
+
task is still \`in_progress\`: before every \`update_task_status\` to "completed" or "blocked" and
|
|
108
|
+
every \`assign_task\`, including those in the role, round-limit and order rules below, and when out
|
|
109
|
+
of room. \`dirty\` and \`wipRef\` are only for work that cannot be committed.
|
|
110
|
+
f. Post your result on the task's own thread (\`mcp__relai__add_task_comment\` or
|
|
101
111
|
\`mcp__relai__send_message\` type "handoff"), with \`toAgent\` set to whoever you
|
|
102
112
|
report to: the task's \`createdBy\` unless told otherwise.
|
|
103
|
-
|
|
113
|
+
g. If the work is done: \`mcp__relai__update_task_status\` \u2192 "completed"
|
|
104
114
|
(or "blocked" if escalating). If another role must continue this same task,
|
|
105
115
|
\`mcp__relai__assign_task\` to them and stop \u2014 do not complete.
|
|
106
116
|
|
|
@@ -225,8 +235,9 @@ types, empty classes) but you do not take the implementation yourself.
|
|
|
225
235
|
|
|
226
236
|
- Read relevant files to understand current structure.
|
|
227
237
|
- Write a clear technical design on this task's thread (\`mcp__relai__add_task_comment\`).
|
|
228
|
-
If scaffolding helps, write it on the branch in \`branchName\`
|
|
229
|
-
same slug rule as writer)
|
|
238
|
+
If scaffolding helps, write it on the branch in \`branchName\`: if set, \`git fetch origin\` then
|
|
239
|
+
\`git checkout -B {branchName} origin/{branchName}\`; if missing, derive one (same slug rule as writer)
|
|
240
|
+
and stamp it as \`metadata.branchName\` with \`mcp__relai__update_task_status\`. Never force-push.
|
|
230
241
|
- \`mcp__relai__list_agents\`, pick specialization \`"writer"\` (prefer online), then
|
|
231
242
|
\`mcp__relai__assign_task\` to them. Do not complete.
|
|
232
243
|
|
|
@@ -239,9 +250,13 @@ types, empty classes) but you do not take the implementation yourself.
|
|
|
239
250
|
You implement specs on a branch and open a PR. Write production code and tests.
|
|
240
251
|
|
|
241
252
|
**Workflow:**
|
|
242
|
-
1. Check metadata for branchName. If
|
|
253
|
+
1. Check metadata for branchName. If set, the branch holds earlier work: \`git fetch origin\` then
|
|
254
|
+
\`git checkout -B {branchName} origin/{branchName}\` (only if the remote branch does not exist, create
|
|
255
|
+
it from the default branch). If none, derive one from the task title:
|
|
243
256
|
slug the title to lowercase-hyphenated, prefix "feat/".
|
|
244
|
-
Use Bash to create the branch: \`git checkout -b {branchName}\`.
|
|
257
|
+
Use Bash to create the branch: \`git checkout -b {branchName}\`. Stamp it on this task via
|
|
258
|
+
\`mcp__relai__update_task_status\` with status \`"in_progress"\` and \`metadata.branchName\`.
|
|
259
|
+
Never force-push to make a push succeed.
|
|
245
260
|
2. Read the spec. Read existing files to understand patterns (Read, Grep, Glob).
|
|
246
261
|
3. Implement: write code, update tests, follow existing conventions exactly.
|
|
247
262
|
4. Run tests: check package.json scripts for the test command.
|
|
@@ -250,7 +265,8 @@ You implement specs on a branch and open a PR. Write production code and tests.
|
|
|
250
265
|
- First try \`gh pr create --fill\` \u2014 captures the PR URL automatically when the GitHub CLI is installed and authed.
|
|
251
266
|
- If \`gh\` isn't available (\`command -v gh\` fails) or it returns a non-zero exit, fall back to \`git push -u origin {branchName}\`. Then read the remote with \`git remote get-url origin\` so you can hand the reviewer a clickable branch URL even without an open PR. Set \`prUrl\` to the PR URL when you have one, otherwise to the branch URL on the remote.
|
|
252
267
|
7. Post a handoff on the task's own thread with \`mcp__relai__add_task_comment\` (type: "handoff"):
|
|
253
|
-
PR URL, head sha, what landed, and tests. Then
|
|
268
|
+
PR URL, head sha, what landed, and tests. Then, after the step e checkpoint,
|
|
269
|
+
\`mcp__relai__update_task_status\` \u2192 "completed".
|
|
254
270
|
- If the task has \`verifyReviewerId\` or any reviewer gate, completed moves it to
|
|
255
271
|
pending_verification. Do not then \`update_task_status\` to \`"in_progress"\` \u2014
|
|
256
272
|
the reviewer decides via \`submit_review\`, and a reject returns the task to
|
|
@@ -263,7 +279,8 @@ You implement specs on a branch and open a PR. Write production code and tests.
|
|
|
263
279
|
|
|
264
280
|
**Playbook rules:**
|
|
265
281
|
- Take the PR head sha from the PR itself (\`gh pr view <n> --json headRefOid\`), never from a handoff message or a task comment.
|
|
266
|
-
- One branch per order, cut from the default branch: never stack on another order's unmerged branch
|
|
282
|
+
- One branch per order, cut from the default branch: never stack on another order's unmerged branch.
|
|
283
|
+
- A test may delete only paths under a directory it created with mkdtemp in that run. Never remove, empty or clear /tmp, $TMPDIR or any other shared directory as a whole, and never point a cleanup or reset at the real one.`;
|
|
267
284
|
case "tester":
|
|
268
285
|
return `## Your role: Tester
|
|
269
286
|
|
|
@@ -277,7 +294,8 @@ Write, fix, and run tests on the branch in branchName metadata.
|
|
|
277
294
|
- If tests cannot pass due to an implementation bug: \`mcp__relai__add_task_comment\`
|
|
278
295
|
(type: "finding") with type "bug", severity "blocking", then
|
|
279
296
|
\`mcp__relai__assign_task\` back to the writer (previous assignee or list_agents).
|
|
280
|
-
Do not complete
|
|
297
|
+
Do not complete.
|
|
298
|
+
- A test may delete only paths under a directory it created with mkdtemp in that run. Never remove, empty or clear /tmp, $TMPDIR or any other shared directory as a whole, and never point a cleanup or reset at the real one.`;
|
|
281
299
|
case "devops":
|
|
282
300
|
return `## Your role: DevOps
|
|
283
301
|
|
|
@@ -293,6 +311,7 @@ Handle CI/CD, infrastructure, build/deploy configuration.
|
|
|
293
311
|
throw new Error(`Unknown specialization "${spec}" \u2014 valid values: reviewer, architect, writer, tester, devops`);
|
|
294
312
|
}
|
|
295
313
|
}
|
|
314
|
+
var PR_FROM_ORDER = "In this section `<prUrl>` is the `prUrl` that `mcp__relai__get_order` returns for `metadata.orderId`. Never use the task's own `metadata.prUrl`. If the `epicId` get_order returns is not this task's own `epicId`, if get_order has no `prUrl`, or the task's `metadata.prUrl` is set and differs from it, run no `gh` command: block the task with a question naming the orchestrator, and stop.";
|
|
296
315
|
var ORDER_RULES = `## Factory orders
|
|
297
316
|
|
|
298
317
|
An order's tasks carry
|
|
@@ -361,7 +380,7 @@ Find the default branch with \`git remote show origin\`.
|
|
|
361
380
|
is done. With one gate, block it on the build tasks alone.
|
|
362
381
|
- \`ci_passed\` gets no gate task: the merge task checks CI. Say so on the planning task's thread.
|
|
363
382
|
5. One merge task, blocked by every gate task (or every build task when there are no gates),
|
|
364
|
-
\`assignedTo: "@auto"\`, \`metadata: { orderId, orderRole: "merge"
|
|
383
|
+
\`assignedTo: "@auto"\`, \`metadata: { orderId, orderRole: "merge" }\`. The server stamps \`prUrl\` on it from the order, so you may omit it.
|
|
365
384
|
If the order has \`pairBuilds\`, every build task needs \`verifyKind: "reviewer_agent"\` and a
|
|
366
385
|
\`verifyReviewerId\` on a different \`workerType\` from the writers (see \`mcp__relai__list_agents\`);
|
|
367
386
|
the API refuses a paired build task without one and routes it away from the reviewer's vendor.
|
|
@@ -374,26 +393,28 @@ Find the default branch with \`git remote show origin\`.
|
|
|
374
393
|
\`metadata.orderKind: "review"\`): the goal is one review posted on the existing pull request at the
|
|
375
394
|
order's \`prUrl\`. There is no order branch, no draft PR and no merge, so skip steps 2 to 6 above.
|
|
376
395
|
Nobody pushes to the PR's branch, approves it, or requests changes on it.
|
|
396
|
+
${PR_FROM_ORDER}
|
|
377
397
|
1. As the planner, read the PR with \`gh pr view <prUrl>\` and \`gh pr diff <prUrl>\`. Pick the
|
|
378
398
|
angles it needs: correctness, security, simplicity, tests and consistency with the codebase for a
|
|
379
399
|
real change; correctness and consistency alone for a small, low-risk one (docs, tests, under about
|
|
380
400
|
150 changed lines with no auth, input handling, secrets, money or migrations).
|
|
381
401
|
2. One task per angle, \`epicId\` = the order's \`epicId\`,
|
|
382
|
-
\`metadata: { orderId, orderRole: "review", angle: "<angle>"
|
|
402
|
+
\`metadata: { orderId, orderRole: "review", angle: "<angle>" }\`; the server stamps \`prUrl\`. Spread the angles
|
|
383
403
|
across vendors: assign each to a different agent from \`mcp__relai__list_agents\`, preferring a
|
|
384
404
|
different \`workerType\` from the agents already given one, and reuse agents only when there are
|
|
385
405
|
more angles than agents. Each description names the angle, the PR, what that angle hunts for, and
|
|
386
406
|
the reporting format below.
|
|
387
407
|
3. One lead task, \`blockedBy\` every angle task, assigned to yourself, with
|
|
388
|
-
\`metadata: { orderId, orderRole: "review_lead"
|
|
408
|
+
\`metadata: { orderId, orderRole: "review_lead" }\`.
|
|
389
409
|
4. When acceptance includes \`human_approval\`, add a gate task blocked by the lead
|
|
390
410
|
(\`metadata: { orderId, orderRole: "gate" }\`, \`verifyKind: "human_approval"\`) and a post task
|
|
391
|
-
blocked by the gate (\`metadata: { orderId, orderRole: "post_review"
|
|
411
|
+
blocked by the gate (\`metadata: { orderId, orderRole: "post_review" }\`).
|
|
392
412
|
5. If the order has \`relatedRepos\`, copy them into every angle and lead task's metadata and name them in
|
|
393
413
|
each description, so reviewers check the contracts this PR depends on in those repos.
|
|
394
414
|
6. Post the task list on the planning task's thread and complete it.
|
|
395
415
|
|
|
396
416
|
**orderRole "review"** (one angle of a review goal):
|
|
417
|
+
- ${PR_FROM_ORDER} For the PR head, \`<n>\` is that PR's number.
|
|
397
418
|
- Read the diff and as much surrounding code as you need. To run anything, check out the PR head
|
|
398
419
|
detached: \`git fetch origin pull/<n>/head && git checkout --detach FETCH_HEAD\`. Never push, never
|
|
399
420
|
comment on the PR, never edit the repo's history.
|
|
@@ -409,6 +430,7 @@ Nobody pushes to the PR's branch, approves it, or requests changes on it.
|
|
|
409
430
|
- Post the report with \`mcp__relai__add_task_comment\` on this task, then complete it.
|
|
410
431
|
|
|
411
432
|
**orderRole "review_lead"**:
|
|
433
|
+
- ${PR_FROM_ORDER}
|
|
412
434
|
- Read every angle task's report with \`mcp__relai__get_task_comments\`. Merge duplicates, drop
|
|
413
435
|
findings you cannot reproduce or whose premise is wrong, and rank what is left. Keep an UNPROVEN mark
|
|
414
436
|
on any finding about a related repo nobody could read. Severity sets the
|
|
@@ -421,7 +443,7 @@ Nobody pushes to the PR's branch, approves it, or requests changes on it.
|
|
|
421
443
|
and put the review URL on the task's thread. With a gate, post the review as a comment on this task
|
|
422
444
|
for the owner to approve, and do not touch the PR.
|
|
423
445
|
|
|
424
|
-
**orderRole "post_review"**:
|
|
446
|
+
**orderRole "post_review"**: ${PR_FROM_ORDER} Once the gate is approved, post the lead's review comment from the
|
|
425
447
|
lead task's thread with \`gh pr review <prUrl> --comment --body-file <file>\`, put the review URL on
|
|
426
448
|
this task's thread, and complete.
|
|
427
449
|
|
|
@@ -451,6 +473,8 @@ this task's thread, and complete.
|
|
|
451
473
|
the rest; if it is not recorded, reject and name it so the builder records it.
|
|
452
474
|
|
|
453
475
|
**orderRole "merge"**:
|
|
476
|
+
${PR_FROM_ORDER}
|
|
477
|
+
|
|
454
478
|
**Playbook rules:**
|
|
455
479
|
- Report a merge you cannot clear under step 3 the same minute: post which check failed, at which sha, and who must act on the merge task's thread, then set the task blocked.
|
|
456
480
|
- Never merge on a stale head: re-read the PR head sha immediately before merging, pass it as \`--match-head-commit\`, and stop if it differs from the sha the gates approved, or the carried head after an Exit 0 conflict-only remerge.
|
|
@@ -1881,7 +1905,8 @@ var PITBOSS_NAMES = [
|
|
|
1881
1905
|
"ENV_GRANT",
|
|
1882
1906
|
"TASK_REPO_ID",
|
|
1883
1907
|
"TASK_REPO_URL",
|
|
1884
|
-
"OWNER_POOL_SLOT"
|
|
1908
|
+
"OWNER_POOL_SLOT",
|
|
1909
|
+
"SESSION_MAX_MS"
|
|
1885
1910
|
].flatMap(envNames);
|
|
1886
1911
|
var WORKER_KNOBS = [
|
|
1887
1912
|
"API_URL",
|
|
@@ -2050,7 +2075,7 @@ function claudeArgs(opts) {
|
|
|
2050
2075
|
...opts.isolateUserConfig ? ["--setting-sources", "project,local"] : []
|
|
2051
2076
|
];
|
|
2052
2077
|
}
|
|
2053
|
-
async function runClaudeSession(config2) {
|
|
2078
|
+
async function runClaudeSession(config2, opts = {}) {
|
|
2054
2079
|
const repoContextOn = readEnv("REPO_CONTEXT") === "1";
|
|
2055
2080
|
const lessonsPromise = repoContextOn ? Promise.resolve("") : lessonsBlock({ ...config2, repoId: taskRepoId(config2) });
|
|
2056
2081
|
const repoContextPromise = repoContextOn ? (readEnv("TASK_REPO_ID")?.trim() ? Promise.resolve(taskRepoId(config2)) : contextRepoId(config2)).then((repoId) => repoContextBlock({ repoId, repoPath: config2.repoPath, role: config2.specialization }, config2)) : Promise.resolve("");
|
|
@@ -2075,7 +2100,8 @@ async function runClaudeSession(config2) {
|
|
|
2075
2100
|
const proc = spawn(config2.claudeBin, args, {
|
|
2076
2101
|
cwd: config2.repoPath,
|
|
2077
2102
|
env: { ...childEnv, PATH: process.env.PATH },
|
|
2078
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
2103
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
2104
|
+
signal: opts.signal
|
|
2079
2105
|
});
|
|
2080
2106
|
console.log(`[claude-worker] Process PID: ${proc.pid}`);
|
|
2081
2107
|
Promise.all([lessonsPromise, repoContextPromise]).then(([lessons, repoContext]) => {
|
|
@@ -2234,7 +2260,7 @@ function readCursorEvent(line) {
|
|
|
2234
2260
|
}
|
|
2235
2261
|
return {};
|
|
2236
2262
|
}
|
|
2237
|
-
async function runCursorSession(config2) {
|
|
2263
|
+
async function runCursorSession(config2, opts = {}) {
|
|
2238
2264
|
const model = pinnedModel();
|
|
2239
2265
|
recordLaunchedModel(model);
|
|
2240
2266
|
const args = cursorArgs(config2.repoPath, buildPrompt(config2, { lessons: await lessonsBlock({ ...config2, repoId: taskRepoId(config2) }) }), model);
|
|
@@ -2242,7 +2268,7 @@ async function runCursorSession(config2) {
|
|
|
2242
2268
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2243
2269
|
const taskIdsPromise = listSessionTaskIds(config2, "[cursor-worker]");
|
|
2244
2270
|
return new Promise((resolve2, reject) => {
|
|
2245
|
-
const proc = spawn2(bin(), args, { cwd: config2.repoPath, env: workerEnv("cursor", process.env, grantFromEnv()), stdio: ["ignore", "pipe", "pipe"] });
|
|
2271
|
+
const proc = spawn2(bin(), args, { cwd: config2.repoPath, env: workerEnv("cursor", process.env, grantFromEnv()), stdio: ["ignore", "pipe", "pipe"], signal: opts.signal });
|
|
2246
2272
|
console.log(`[cursor-worker] Process PID: ${proc.pid}`);
|
|
2247
2273
|
const toolsUsed = [];
|
|
2248
2274
|
let buffer = "";
|
|
@@ -2354,7 +2380,7 @@ function readCopilotEvent(line) {
|
|
|
2354
2380
|
if (type === "session.warning" && POLICY_BLOCKS.some((re) => re.test(message))) return { error: message };
|
|
2355
2381
|
return {};
|
|
2356
2382
|
}
|
|
2357
|
-
async function runCopilotSession(config2) {
|
|
2383
|
+
async function runCopilotSession(config2, opts = {}) {
|
|
2358
2384
|
const mcpConfigPath = writeMcpConfig(config2, "copilot-worker");
|
|
2359
2385
|
const model = pinnedModel();
|
|
2360
2386
|
recordLaunchedModel(model);
|
|
@@ -2363,7 +2389,7 @@ async function runCopilotSession(config2) {
|
|
|
2363
2389
|
const taskIdsPromise = listSessionTaskIds(config2, "[copilot-worker]");
|
|
2364
2390
|
console.log(`[copilot-worker] Spawning: ${bin2()} -p <prompt> ${args.slice(2, 6).join(" ")} ...`);
|
|
2365
2391
|
return new Promise((resolve2, reject) => {
|
|
2366
|
-
const proc = spawn3(bin2(), args, { cwd: config2.repoPath, env: workerEnv("copilot", process.env, grantFromEnv()), stdio: ["ignore", "pipe", "pipe"] });
|
|
2392
|
+
const proc = spawn3(bin2(), args, { cwd: config2.repoPath, env: workerEnv("copilot", process.env, grantFromEnv()), stdio: ["ignore", "pipe", "pipe"], signal: opts.signal });
|
|
2367
2393
|
console.log(`[copilot-worker] Process PID: ${proc.pid}`);
|
|
2368
2394
|
const toolsUsed = [];
|
|
2369
2395
|
let buffer = "";
|
|
@@ -2481,7 +2507,7 @@ function readCodexEvent(line) {
|
|
|
2481
2507
|
if (event.type === "error") return { error: String(event.message ?? "codex error") };
|
|
2482
2508
|
return {};
|
|
2483
2509
|
}
|
|
2484
|
-
async function runCodexSession(config2) {
|
|
2510
|
+
async function runCodexSession(config2, opts = {}) {
|
|
2485
2511
|
const profile = `pitboss-${config2.agentId}`;
|
|
2486
2512
|
mkdirSync3(codexHome(), { recursive: true, mode: 448 });
|
|
2487
2513
|
const profilePath = join5(codexHome(), `${profile}.config.toml`);
|
|
@@ -2507,7 +2533,7 @@ async function runCodexSession(config2) {
|
|
|
2507
2533
|
const taskIdsPromise = listSessionTaskIds(config2, "[codex-worker]");
|
|
2508
2534
|
console.log(`[codex-worker] Spawning: ${bin3()} ${args.slice(0, 6).join(" ")} ...`);
|
|
2509
2535
|
return new Promise((resolve2, reject) => {
|
|
2510
|
-
const proc = spawn4(bin3(), args, { cwd: config2.repoPath, env: workerEnv("codex", process.env, grantFromEnv()), stdio: ["ignore", "pipe", "pipe"] });
|
|
2536
|
+
const proc = spawn4(bin3(), args, { cwd: config2.repoPath, env: workerEnv("codex", process.env, grantFromEnv()), stdio: ["ignore", "pipe", "pipe"], signal: opts.signal });
|
|
2511
2537
|
console.log(`[codex-worker] Process PID: ${proc.pid}`);
|
|
2512
2538
|
const toolsUsed = [];
|
|
2513
2539
|
let buffer = "";
|
|
@@ -2741,6 +2767,13 @@ async function clearUnavailable(config2, logPrefix = "[claude-worker]") {
|
|
|
2741
2767
|
}
|
|
2742
2768
|
|
|
2743
2769
|
// ../claude-worker/src/worker.ts
|
|
2770
|
+
var SESSION_HEARTBEAT_MS = 6e4;
|
|
2771
|
+
var DEFAULT_SESSION_MAX_MS = 90 * 6e4;
|
|
2772
|
+
var MAX_TIMER_MS = 2147483647;
|
|
2773
|
+
function sessionMaxMs(source = process.env) {
|
|
2774
|
+
const ms = Number(readEnv("SESSION_MAX_MS", source) ?? NaN);
|
|
2775
|
+
return Number.isFinite(ms) && ms >= 0 ? Math.min(ms, MAX_TIMER_MS) : DEFAULT_SESSION_MAX_MS;
|
|
2776
|
+
}
|
|
2744
2777
|
async function heartbeat(config2, logPrefix = "[claude-worker]") {
|
|
2745
2778
|
await fetch(`${config2.apiUrl}/agents/${config2.agentId}/heartbeat`, {
|
|
2746
2779
|
method: "PUT",
|
|
@@ -2748,6 +2781,17 @@ async function heartbeat(config2, logPrefix = "[claude-worker]") {
|
|
|
2748
2781
|
body: "{}"
|
|
2749
2782
|
}).catch((err) => console.warn(`${logPrefix} Heartbeat failed:`, err.message));
|
|
2750
2783
|
}
|
|
2784
|
+
async function withHeartbeat(config2, logPrefix, session) {
|
|
2785
|
+
const maxMs = sessionMaxMs();
|
|
2786
|
+
const quiet = maxMs > 0 ? setTimeout(() => clearInterval(beat), maxMs) : void 0;
|
|
2787
|
+
const beat = setInterval(() => void heartbeat(config2, logPrefix), SESSION_HEARTBEAT_MS);
|
|
2788
|
+
try {
|
|
2789
|
+
return await session();
|
|
2790
|
+
} finally {
|
|
2791
|
+
clearTimeout(quiet);
|
|
2792
|
+
clearInterval(beat);
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2751
2795
|
async function assertRepoOrExit(config2, logPrefix = "[claude-worker]") {
|
|
2752
2796
|
const repoUrl = await fetchRepoUrl(config2.apiUrl, config2.repoId, config2.apiSecret);
|
|
2753
2797
|
const check = checkRepoMatch(config2.repoPath, repoUrl);
|
|
@@ -3649,6 +3693,32 @@ async function checkWork(config2) {
|
|
|
3649
3693
|
repoIds: [...new Set(myTasks.map((t) => t.repoId).filter((id) => typeof id === "string"))].sort()
|
|
3650
3694
|
};
|
|
3651
3695
|
}
|
|
3696
|
+
async function recordSessionTimeout(config2, taskId, minutes, timeouts) {
|
|
3697
|
+
const headers = { "Content-Type": "application/json", Authorization: `Bearer ${config2.apiSecret}` };
|
|
3698
|
+
const count = (timeouts.get(taskId) ?? 0) + 1;
|
|
3699
|
+
timeouts.set(taskId, count);
|
|
3700
|
+
try {
|
|
3701
|
+
const res = await fetch(`${config2.apiUrl}/tasks/${taskId}`, { headers });
|
|
3702
|
+
if (!res.ok) throw new Error(`task lookup failed (${res.status} ${res.statusText})`);
|
|
3703
|
+
const { data: task } = await res.json();
|
|
3704
|
+
const mine = task?.assignedTo === config2.agentId;
|
|
3705
|
+
if (!mine) console.error(`[event-worker] ${taskId} is not assigned to this agent (a review, or reassigned), so the timeout is a comment only`);
|
|
3706
|
+
const block = mine && count >= 2;
|
|
3707
|
+
const out = block ? await fetch(`${config2.apiUrl}/tasks/${taskId}`, {
|
|
3708
|
+
method: "PUT",
|
|
3709
|
+
headers,
|
|
3710
|
+
body: JSON.stringify({ status: "blocked", metadata: { blockedReason: `The session cap (${minutes} minutes) was hit twice on this task. Each session was killed. Split the task or find out why it hangs before reassigning it.` } })
|
|
3711
|
+
}) : await fetch(`${config2.apiUrl}/tasks/${taskId}/comments`, {
|
|
3712
|
+
method: "POST",
|
|
3713
|
+
headers,
|
|
3714
|
+
body: JSON.stringify({ body: `The session working this task hit the ${minutes}-minute cap and was killed. The task stays as it is, and the next wake resumes it in a fresh session.` })
|
|
3715
|
+
});
|
|
3716
|
+
if (!out.ok) console.error(`[event-worker] Could not record the session timeout on ${taskId} (${out.status} ${out.statusText})`);
|
|
3717
|
+
else if (block) timeouts.delete(taskId);
|
|
3718
|
+
} catch (err) {
|
|
3719
|
+
console.error("[event-worker] Could not record the session timeout:", err instanceof Error ? err.message : String(err));
|
|
3720
|
+
}
|
|
3721
|
+
}
|
|
3652
3722
|
var EVENT_KINDS = [
|
|
3653
3723
|
"message.posted",
|
|
3654
3724
|
"task.created",
|
|
@@ -3662,6 +3732,9 @@ var EVENT_KINDS = [
|
|
|
3662
3732
|
"task.blocked",
|
|
3663
3733
|
"task.pending_verification",
|
|
3664
3734
|
"task.stalled",
|
|
3735
|
+
"task.stall_released",
|
|
3736
|
+
"task.down_released",
|
|
3737
|
+
"task.rerouted",
|
|
3665
3738
|
"task.verified",
|
|
3666
3739
|
"task.verification_failed",
|
|
3667
3740
|
"task.review_requested",
|
|
@@ -3690,12 +3763,29 @@ function endSession(file) {
|
|
|
3690
3763
|
}
|
|
3691
3764
|
}
|
|
3692
3765
|
}
|
|
3766
|
+
function endsTask(event, taskId) {
|
|
3767
|
+
const task = event?.payload?.task;
|
|
3768
|
+
if (!task || task.id !== taskId) return false;
|
|
3769
|
+
return task.status === "cancelled" || task.archivedAt !== void 0 && task.archivedAt !== null;
|
|
3770
|
+
}
|
|
3771
|
+
var RELEASE_KINDS = ["task.stall_released", "task.down_released"];
|
|
3772
|
+
function releasedTask(event, taskId) {
|
|
3773
|
+
const e = event;
|
|
3774
|
+
return e?.targetId === taskId && typeof e.kind === "string" && RELEASE_KINDS.includes(e.kind);
|
|
3775
|
+
}
|
|
3776
|
+
function reroutedAway(event, taskId, agentId) {
|
|
3777
|
+
const e = event;
|
|
3778
|
+
if (e?.kind !== "task.rerouted" || e.targetId !== taskId) return false;
|
|
3779
|
+
return e.payload?.previousAssignee === agentId && typeof e.payload.assignedTo === "string" && e.payload.assignedTo !== agentId;
|
|
3780
|
+
}
|
|
3693
3781
|
async function runEventWorker(config2) {
|
|
3694
3782
|
console.log(`[event-worker] Starting \u2014 agent ${config2.agentId}, watching ${config2.apiUrl}/events`);
|
|
3695
3783
|
console.log(`[event-worker] Repo: ${config2.repoPath} | Runtime: ${config2.runtime} | Model: ${config2.model}`);
|
|
3696
3784
|
await assertRepoOrExit(config2, "[event-worker]");
|
|
3697
3785
|
await assertNotOwnerScopedOrExit(config2, "[event-worker]");
|
|
3698
3786
|
await selfSubscribe(config2);
|
|
3787
|
+
let live = null;
|
|
3788
|
+
const timeouts = /* @__PURE__ */ new Map();
|
|
3699
3789
|
const queue = createRunQueue(async () => {
|
|
3700
3790
|
await heartbeat(config2, "[event-worker]");
|
|
3701
3791
|
const slotMode = readEnv("OWNER_POOL_SLOT") === "1";
|
|
@@ -3749,6 +3839,9 @@ async function runEventWorker(config2) {
|
|
|
3749
3839
|
return;
|
|
3750
3840
|
}
|
|
3751
3841
|
}
|
|
3842
|
+
const controller = new AbortController();
|
|
3843
|
+
const maxMs = sessionMaxMs();
|
|
3844
|
+
let timedOut = false;
|
|
3752
3845
|
try {
|
|
3753
3846
|
console.log("[event-worker] Event received \u2014 running session...");
|
|
3754
3847
|
const activityFile = readEnv("ACTIVITY_FILE");
|
|
@@ -3767,9 +3860,24 @@ async function runEventWorker(config2) {
|
|
|
3767
3860
|
setEnv(TASK_REPO_ID_ENV, checkout.repoId);
|
|
3768
3861
|
setEnv(TASK_REPO_URL_ENV, checkout.repoUrl);
|
|
3769
3862
|
}
|
|
3863
|
+
if (checkout) live = { taskId: checkout.taskId, controller };
|
|
3864
|
+
const cap = maxMs > 0 ? setTimeout(() => {
|
|
3865
|
+
if (controller.signal.aborted) return;
|
|
3866
|
+
timedOut = true;
|
|
3867
|
+
controller.abort();
|
|
3868
|
+
}, maxMs) : void 0;
|
|
3770
3869
|
try {
|
|
3771
|
-
await getAdapter(config2.runtime).runSession(config2);
|
|
3870
|
+
await withHeartbeat(config2, "[event-worker]", () => getAdapter(config2.runtime).runSession(config2, { signal: controller.signal }));
|
|
3871
|
+
if (checkout) timeouts.delete(checkout.taskId);
|
|
3772
3872
|
} finally {
|
|
3873
|
+
clearTimeout(cap);
|
|
3874
|
+
live = null;
|
|
3875
|
+
if (controller.signal.aborted && readEnv(RESET_SEAT_DIR_ENV)) {
|
|
3876
|
+
try {
|
|
3877
|
+
await killSeatProcesses();
|
|
3878
|
+
} catch {
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3773
3881
|
if (slotMode) envRestore(priorPin);
|
|
3774
3882
|
if (checkout) {
|
|
3775
3883
|
envRestore(priorTaskRepo.id);
|
|
@@ -3785,6 +3893,16 @@ async function runEventWorker(config2) {
|
|
|
3785
3893
|
await clearUnavailable(config2, "[event-worker]");
|
|
3786
3894
|
} catch (err) {
|
|
3787
3895
|
const text = err instanceof Error ? err.message : String(err);
|
|
3896
|
+
if (timedOut) {
|
|
3897
|
+
const minutes = Math.max(1, Math.ceil(maxMs / 6e4));
|
|
3898
|
+
console.error(`[event-worker] Session hit the ${minutes}-minute cap (SESSION_MAX_MS) and was killed${checkout ? "" : "; it had no tracked task, so no task was written"}`);
|
|
3899
|
+
if (checkout) await recordSessionTimeout(config2, checkout.taskId, minutes, timeouts);
|
|
3900
|
+
return;
|
|
3901
|
+
}
|
|
3902
|
+
if (controller.signal.aborted) {
|
|
3903
|
+
console.log(`[event-worker] Session stopped because its task was ${controller.signal.reason} (${text.split("\n")[0].slice(0, 120)})`);
|
|
3904
|
+
return;
|
|
3905
|
+
}
|
|
3788
3906
|
const exitActivityFile = readEnv("ACTIVITY_FILE");
|
|
3789
3907
|
if (exitActivityFile) {
|
|
3790
3908
|
try {
|
|
@@ -3829,6 +3947,11 @@ async function runEventWorker(config2) {
|
|
|
3829
3947
|
try {
|
|
3830
3948
|
const event = JSON.parse(raw.data);
|
|
3831
3949
|
console.log(`[event-worker] Event: ${event.kind ?? "unknown"}`);
|
|
3950
|
+
const why = !live || live.controller.signal.aborted ? null : endsTask(event, live.taskId) ? "cancelled or archived" : releasedTask(event, live.taskId) ? "released to the queue" : reroutedAway(event, live.taskId, config2.agentId) ? "rerouted to another agent" : null;
|
|
3951
|
+
if (live && why) {
|
|
3952
|
+
console.warn(`[event-worker] task ${live.taskId} was ${why} \u2014 stopping its session`);
|
|
3953
|
+
live.controller.abort(why);
|
|
3954
|
+
}
|
|
3832
3955
|
} catch {
|
|
3833
3956
|
}
|
|
3834
3957
|
queue.notify();
|
package/dist/mcp.js
CHANGED
|
@@ -12343,7 +12343,8 @@ var ApiClient = class {
|
|
|
12343
12343
|
this.headers = {
|
|
12344
12344
|
"Content-Type": "application/json",
|
|
12345
12345
|
Authorization: `Bearer ${config.secret}`,
|
|
12346
|
-
...config.ownerId ? { "X-Owner-Id": config.ownerId } : {}
|
|
12346
|
+
...config.ownerId ? { "X-Owner-Id": config.ownerId } : {},
|
|
12347
|
+
...config.sessionId ? { "X-Shipster-Session": config.sessionId } : {}
|
|
12347
12348
|
};
|
|
12348
12349
|
}
|
|
12349
12350
|
// For routes with sibling fields next to `data` (e.g. the unread feed's
|
|
@@ -12653,6 +12654,9 @@ var ApiClient = class {
|
|
|
12653
12654
|
releaseTask(taskId, force) {
|
|
12654
12655
|
return this.request("POST", `/tasks/${taskId}/release`, force === void 0 ? {} : { force });
|
|
12655
12656
|
}
|
|
12657
|
+
checkpointTask(taskId, checkpoint) {
|
|
12658
|
+
return this.request("POST", `/tasks/${taskId}/checkpoint`, checkpoint);
|
|
12659
|
+
}
|
|
12656
12660
|
archiveThread(threadId) {
|
|
12657
12661
|
return this.request("PUT", `/threads/${threadId}/archive`, {});
|
|
12658
12662
|
}
|
|
@@ -12709,6 +12713,26 @@ var ApiClient = class {
|
|
|
12709
12713
|
}
|
|
12710
12714
|
};
|
|
12711
12715
|
|
|
12716
|
+
// ../mcp-server/src/session-id.ts
|
|
12717
|
+
import { randomBytes } from "node:crypto";
|
|
12718
|
+
var SESSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
12719
|
+
var URL_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
|
|
12720
|
+
var generated;
|
|
12721
|
+
function generate() {
|
|
12722
|
+
const bytes = randomBytes(12);
|
|
12723
|
+
let out = "ses_";
|
|
12724
|
+
for (const b of bytes) out += URL_SAFE[b % URL_SAFE.length];
|
|
12725
|
+
return out;
|
|
12726
|
+
}
|
|
12727
|
+
function resolveSessionId(env2 = process.env) {
|
|
12728
|
+
for (const name of ["CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID"]) {
|
|
12729
|
+
const value = env2[name];
|
|
12730
|
+
if (value && SESSION_ID_PATTERN.test(value)) return value;
|
|
12731
|
+
}
|
|
12732
|
+
generated ??= generate();
|
|
12733
|
+
return generated;
|
|
12734
|
+
}
|
|
12735
|
+
|
|
12712
12736
|
// ../mcp-server/src/register-tools.ts
|
|
12713
12737
|
init_zod();
|
|
12714
12738
|
|
|
@@ -12856,7 +12880,7 @@ function pausedExplanation(held) {
|
|
|
12856
12880
|
return {
|
|
12857
12881
|
omitted: held.length,
|
|
12858
12882
|
pauses: [...seen.values()],
|
|
12859
|
-
instruction: "The owner paused new work. Start nothing new: finish the current step of any in_progress task, commit or push what you have,
|
|
12883
|
+
instruction: "The owner paused new work. Start nothing new: finish the current step of any in_progress task, commit or push what you have, save a checkpoint with checkpoint_task, and stop."
|
|
12860
12884
|
};
|
|
12861
12885
|
}
|
|
12862
12886
|
function listTasksLimit() {
|
|
@@ -12925,7 +12949,7 @@ function buildOrderReadTools(client, repoId, opts) {
|
|
|
12925
12949
|
return [
|
|
12926
12950
|
{
|
|
12927
12951
|
name: "get_order",
|
|
12928
|
-
description: "Read one factory order by id. Status is derived from the Epic's child tasks and acceptance gates, not stored transitions. Also returns `tasks` (id, title, status, assignee, orderRole, blockedBy, model, cost), `gates` (kind, status, reviewer, decision), `cost` ({ totalUsd, totalTurns, byModel, tasksReporting, tasksTotal, note }) and `models`. The cost covers only what workers have reported so far: read its `note` before quoting the total. " + ORDER_GATE_NOTE,
|
|
12952
|
+
description: "Read one factory order by id. Status is derived from the Epic's child tasks and acceptance gates, not stored transitions. Also returns `tasks` (id, title, status, assignee, orderRole, blockedBy, model, cost), `gates` (kind, status, reviewer, decision), `cost` ({ totalUsd, totalTurns, byModel, byTier, tasksReporting, tasksTotal, note }) and `models`. The cost covers only what workers have reported so far: read its `note` before quoting the total. " + ORDER_GATE_NOTE,
|
|
12929
12953
|
inputSchema: external_exports.object({
|
|
12930
12954
|
id: external_exports.string().describe("The order id (ord_\u2026).")
|
|
12931
12955
|
}),
|
|
@@ -13694,7 +13718,7 @@ ${version.body}`
|
|
|
13694
13718
|
},
|
|
13695
13719
|
{
|
|
13696
13720
|
name: "get_my_tasks",
|
|
13697
|
-
description: "Retrieve tasks assigned to this agent. Use this at the start of a session or when you want to know what work is queued for you. Returns tasks with status 'assigned' or 'in_progress'. Always call this before starting new work so you don't duplicate effort. humanLabel 'Not picked up' means this task was assigned to you hours ago and nothing has touched it since \u2014 the assignment notice never reached you. Start it now, or say on its thread why you are not going to. Capped and newest-first, with descriptions clipped \u2014 an uncapped queue once blew past the tool-result limit. A task still 'assigned' inside a paused scope (repo, order or account) is left out and explained in a top-level `paused` block: the owner paused new work, so start nothing new; in_progress tasks stay listed so you can finish the step and
|
|
13721
|
+
description: "Retrieve tasks assigned to this agent. Use this at the start of a session or when you want to know what work is queued for you. Returns tasks with status 'assigned' or 'in_progress'. Always call this before starting new work so you don't duplicate effort. humanLabel 'Not picked up' means this task was assigned to you hours ago and nothing has touched it since \u2014 the assignment notice never reached you. Start it now, or say on its thread why you are not going to. Capped and newest-first, with descriptions clipped \u2014 an uncapped queue once blew past the tool-result limit. A task still 'assigned' inside a paused scope (repo, order or account) is left out and explained in a top-level `paused` block: the owner paused new work, so start nothing new; in_progress tasks stay listed so you can finish the step and save a checkpoint with checkpoint_task. taskCount is the true total; raise `limit` to see more. A row marked truncated:true is a pointer \u2014 call get_task for the full description.",
|
|
13698
13722
|
inputSchema: external_exports.object({
|
|
13699
13723
|
status: external_exports.enum(["assigned", "in_progress", "pending", "all"]).optional().describe("Filter by task status. Omit for both 'assigned' and 'in_progress'."),
|
|
13700
13724
|
limit: external_exports.number().int().min(1).optional().describe("Max tasks to return, newest-updated first. Defaults to 50.")
|
|
@@ -13772,6 +13796,33 @@ ${version.body}`
|
|
|
13772
13796
|
});
|
|
13773
13797
|
}
|
|
13774
13798
|
},
|
|
13799
|
+
{
|
|
13800
|
+
name: "checkpoint_task",
|
|
13801
|
+
description: "Save your work-in-progress state on an in_progress task you hold. The next session on it remembers nothing of this one and starts from this checkpoint. Call it before you stop working the task for any reason, and after any step you would not want redone. Each call replaces the previous checkpoint. You keep the task; with a pause pending the checkpoint releases it and the result says to stop.",
|
|
13802
|
+
inputSchema: external_exports.object({
|
|
13803
|
+
taskId: external_exports.string().describe("The task you hold."),
|
|
13804
|
+
branch: external_exports.string().describe("Branch the work is on."),
|
|
13805
|
+
headSha: external_exports.string().describe("Full sha of your last commit."),
|
|
13806
|
+
worktree: external_exports.string().optional().describe("Checkout path, if not the repo path."),
|
|
13807
|
+
done: external_exports.array(external_exports.string()).describe("Finished and proved, with the evidence."),
|
|
13808
|
+
next: external_exports.array(external_exports.string()).describe("What to do next, in order."),
|
|
13809
|
+
unverified: external_exports.array(external_exports.string()).describe("Claims not yet proved, and what would prove each."),
|
|
13810
|
+
dirty: external_exports.boolean().optional().describe("Uncommitted changes exist. Needs wipRef."),
|
|
13811
|
+
wipRef: external_exports.string().optional().describe("Pushed ref holding them: refs/wip/<taskId>.")
|
|
13812
|
+
}),
|
|
13813
|
+
handler: async (input) => {
|
|
13814
|
+
if (input.dirty && !input.wipRef) {
|
|
13815
|
+
return mcpFail(new Error("A dirty checkpoint needs wipRef: push the uncommitted work to a ref first."));
|
|
13816
|
+
}
|
|
13817
|
+
const { taskId, ...checkpoint } = input;
|
|
13818
|
+
try {
|
|
13819
|
+
const { task } = await client.checkpointTask(taskId, checkpoint);
|
|
13820
|
+
return { content: [{ type: "text", text: !task.assignedTo ? `Checkpoint saved and task ${taskId} released: a pause was pending. Stop working on it now.` : `Checkpoint saved on task ${taskId}. You still hold it.` }] };
|
|
13821
|
+
} catch (err) {
|
|
13822
|
+
return mcpFail(err);
|
|
13823
|
+
}
|
|
13824
|
+
}
|
|
13825
|
+
},
|
|
13775
13826
|
buildUpdateTaskTool(client),
|
|
13776
13827
|
{
|
|
13777
13828
|
name: "send_message",
|
|
@@ -15190,7 +15241,10 @@ if (OWNER_MODE) {
|
|
|
15190
15241
|
var apiClient = new ApiClient({
|
|
15191
15242
|
baseUrl: API_URL,
|
|
15192
15243
|
secret: OWNER_MODE ? API_OWNER_TOKEN : API_SECRET,
|
|
15193
|
-
ownerId: OWNER_MODE ? OWNER_ID : void 0
|
|
15244
|
+
ownerId: OWNER_MODE ? OWNER_ID : void 0,
|
|
15245
|
+
// stdio is one process per agent session, so one id per process is right.
|
|
15246
|
+
// HTTP serves several callers from this single client, so it sends none.
|
|
15247
|
+
sessionId: TRANSPORT === "stdio" ? resolveSessionId() : void 0
|
|
15194
15248
|
});
|
|
15195
15249
|
var server = createMcpServer(OWNER_MODE ? "relai-operator" : "relai", pkg.version);
|
|
15196
15250
|
var toolConfig = OWNER_MODE ? { ownerMode: true, ownerId: OWNER_ID } : { ownerMode: false, agentId: AGENT_ID, repoId: REPO_ID };
|