@nanocollective/roster 0.1.0-alpha.41 → 0.1.0-alpha.43
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
CHANGED
|
@@ -5356,11 +5356,56 @@ async function act(req) {
|
|
|
5356
5356
|
if (!flag) throw new Error(`unknown merge method "${req.mergeMethod}"`);
|
|
5357
5357
|
const args = ["pr", "merge", n, "--repo", repo, flag];
|
|
5358
5358
|
if (req.body?.trim() && how !== "rebase") args.push("--body", req.body.trim());
|
|
5359
|
-
|
|
5359
|
+
try {
|
|
5360
|
+
await run3("gh", args, { encoding: "utf8" });
|
|
5361
|
+
} catch (err) {
|
|
5362
|
+
throw new Error(whyNotMerged(errText(err), await prState(repo, n)));
|
|
5363
|
+
}
|
|
5360
5364
|
return { ok: true, action, mergedBy: how };
|
|
5361
5365
|
}
|
|
5362
5366
|
throw new Error(`unknown action "${action}"`);
|
|
5363
5367
|
}
|
|
5368
|
+
function whyNotMerged(ghError, pr) {
|
|
5369
|
+
const checks = pr?.statusCheckRollup ?? [];
|
|
5370
|
+
const name = (c) => c.name || c.context || "a check";
|
|
5371
|
+
const running = checks.filter(
|
|
5372
|
+
(c) => c.status && c.status !== "COMPLETED" || c.state === "PENDING" || c.state === "EXPECTED"
|
|
5373
|
+
);
|
|
5374
|
+
const failing = checks.filter(
|
|
5375
|
+
(c) => ["FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "ERROR"].includes(
|
|
5376
|
+
String(c.conclusion || c.state || "")
|
|
5377
|
+
)
|
|
5378
|
+
);
|
|
5379
|
+
const list = (cs) => cs.map(name).join(", ");
|
|
5380
|
+
if (pr?.mergeStateStatus === "BLOCKED" && failing.length)
|
|
5381
|
+
return `Not merged: checks failed (${list(failing)}).`;
|
|
5382
|
+
if (pr?.mergeStateStatus === "BLOCKED" && running.length)
|
|
5383
|
+
return `Not merged: checks are still running (${list(running)}). Merge again once they pass.`;
|
|
5384
|
+
if (pr?.mergeStateStatus === "BLOCKED")
|
|
5385
|
+
return "Not merged: the branch rules require something first, such as a review. Open it in GitHub to see what.";
|
|
5386
|
+
if (pr?.mergeStateStatus === "BEHIND")
|
|
5387
|
+
return "Not merged: the branch is behind its base, and the branch rules require it to be up to date.";
|
|
5388
|
+
if (pr?.mergeStateStatus === "DIRTY") return "Not merged: the branch conflicts with its base.";
|
|
5389
|
+
const line = ghError.split("\n").map((l) => l.replace(/^[!X✗]\s*/, "").trim()).find((l) => l && !l.startsWith("Command failed") && !/--auto|--admin/.test(l));
|
|
5390
|
+
return `Not merged: ${line ?? "GitHub refused it without saying why."}`;
|
|
5391
|
+
}
|
|
5392
|
+
function errText(err) {
|
|
5393
|
+
const e = err;
|
|
5394
|
+
return `${e?.stderr ?? ""}
|
|
5395
|
+
${e?.message ?? ""}`;
|
|
5396
|
+
}
|
|
5397
|
+
async function prState(repo, n) {
|
|
5398
|
+
try {
|
|
5399
|
+
const { stdout } = await run3(
|
|
5400
|
+
"gh",
|
|
5401
|
+
["pr", "view", n, "--repo", repo, "--json", "mergeStateStatus,statusCheckRollup"],
|
|
5402
|
+
{ encoding: "utf8" }
|
|
5403
|
+
);
|
|
5404
|
+
return JSON.parse(stdout);
|
|
5405
|
+
} catch {
|
|
5406
|
+
return null;
|
|
5407
|
+
}
|
|
5408
|
+
}
|
|
5364
5409
|
async function mergeMethodFor(repo) {
|
|
5365
5410
|
try {
|
|
5366
5411
|
const { stdout } = await run3(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanocollective/roster",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.43",
|
|
4
4
|
"description": "An agent-run org, powered by GitHub. Scaffold AI staff members whose brain is a repo.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,23 @@
|
|
|
16
16
|
"engines": {
|
|
17
17
|
"node": ">=20"
|
|
18
18
|
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup src/cli.ts --format esm --target node20 --clean",
|
|
21
|
+
"dev": "tsx src/cli.ts",
|
|
22
|
+
"test": "tsx --test test/*.test.ts",
|
|
23
|
+
"test:all": "pnpm test:format && pnpm test:lint && pnpm test:types && pnpm test:knip && pnpm test",
|
|
24
|
+
"test:ava:coverage": "c8 --reporter=text --reporter=json-summary tsx --test test/*.test.ts",
|
|
25
|
+
"test:format": "biome check --no-errors-on-unmatched .",
|
|
26
|
+
"test:lint": "biome lint .",
|
|
27
|
+
"test:lint:fix": "biome check --write .",
|
|
28
|
+
"test:types": "tsc --noEmit",
|
|
29
|
+
"test:knip": "knip",
|
|
30
|
+
"test:audit": "pnpm audit --audit-level=high",
|
|
31
|
+
"test:security": "semgrep scan --config auto --error",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"format": "biome check --write .",
|
|
34
|
+
"prepublishOnly": "pnpm test:all && pnpm build"
|
|
35
|
+
},
|
|
19
36
|
"devDependencies": {
|
|
20
37
|
"@biomejs/biome": "^2.5.12",
|
|
21
38
|
"@types/node": "^22.10.2",
|
|
@@ -35,24 +52,9 @@
|
|
|
35
52
|
"url": "https://github.com/Nano-Collective/roster/issues"
|
|
36
53
|
},
|
|
37
54
|
"author": "Nano Collective",
|
|
55
|
+
"packageManager": "pnpm@11.0.9",
|
|
38
56
|
"publishConfig": {
|
|
39
57
|
"access": "public",
|
|
40
58
|
"tag": "latest"
|
|
41
|
-
},
|
|
42
|
-
"scripts": {
|
|
43
|
-
"build": "tsup src/cli.ts --format esm --target node20 --clean",
|
|
44
|
-
"dev": "tsx src/cli.ts",
|
|
45
|
-
"test": "tsx --test test/*.test.ts",
|
|
46
|
-
"test:all": "pnpm test:format && pnpm test:lint && pnpm test:types && pnpm test:knip && pnpm test",
|
|
47
|
-
"test:ava:coverage": "c8 --reporter=text --reporter=json-summary tsx --test test/*.test.ts",
|
|
48
|
-
"test:format": "biome check --no-errors-on-unmatched .",
|
|
49
|
-
"test:lint": "biome lint .",
|
|
50
|
-
"test:lint:fix": "biome check --write .",
|
|
51
|
-
"test:types": "tsc --noEmit",
|
|
52
|
-
"test:knip": "knip",
|
|
53
|
-
"test:audit": "pnpm audit --audit-level=high",
|
|
54
|
-
"test:security": "semgrep scan --config auto --error",
|
|
55
|
-
"typecheck": "tsc --noEmit",
|
|
56
|
-
"format": "biome check --write ."
|
|
57
59
|
}
|
|
58
|
-
}
|
|
60
|
+
}
|
|
@@ -79,6 +79,14 @@ jobs:
|
|
|
79
79
|
# secrets are not usable in a step-level `if`, so the presence check is hoisted here.
|
|
80
80
|
env:
|
|
81
81
|
HAS_PUBLIC_APP: ${{ secrets.PUBLIC_APP_ID != '' }}
|
|
82
|
+
# Nothing is listening once the agent ends its turn: the session is over and a background
|
|
83
|
+
# job dies with the runner. Claude Code moves any command past its timeout (ten minutes by
|
|
84
|
+
# default) into the background, and an agent told "you will be notified" ends its turn to
|
|
85
|
+
# wait, so a slow test suite cost the whole run, unpushed, reported as a success. With
|
|
86
|
+
# background tasks off a slow command stays in the foreground, and the higher cap lets a
|
|
87
|
+
# long gate finish inside the turn. timeout-minutes is still the real bound.
|
|
88
|
+
CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1"
|
|
89
|
+
BASH_MAX_TIMEOUT_MS: "2700000"
|
|
82
90
|
|
|
83
91
|
steps:
|
|
84
92
|
# For the run record at the end. A job has no start time of its own to read back.
|
|
@@ -341,6 +349,37 @@ jobs:
|
|
|
341
349
|
fi
|
|
342
350
|
eval "$RUN"
|
|
343
351
|
|
|
352
|
+
# A session can exit cleanly without doing what it was asked: the success above only means
|
|
353
|
+
# the agent stopped. A mention is answered by a reply in its thread, or by the issue being
|
|
354
|
+
# closed when the request said to answer somewhere else. When neither happened the job
|
|
355
|
+
# fails, so the notice below tells the human instead of leaving them waiting.
|
|
356
|
+
#
|
|
357
|
+
# Never fails on its own account: if the issue cannot be read, nothing is claimed.
|
|
358
|
+
- name: Check the request was answered
|
|
359
|
+
id: answered
|
|
360
|
+
if: >-
|
|
361
|
+
inputs.kind == 'mention' && inputs.issue_number != ''
|
|
362
|
+
&& (steps.session_action.outcome == 'success' || steps.session_cli.outcome == 'success')
|
|
363
|
+
env:
|
|
364
|
+
GH_TOKEN: ${{ steps.private.outputs.token }}
|
|
365
|
+
BOT: ${{ steps.private.outputs.app-slug }}[bot]
|
|
366
|
+
ISSUE: ${{ inputs.issue_number }}
|
|
367
|
+
run: |
|
|
368
|
+
set -uo pipefail
|
|
369
|
+
since=$(date -u -d "@$ROSTER_STARTED" +%Y-%m-%dT%H:%M:%SZ)
|
|
370
|
+
if ! state=$(gh api "repos/${{ github.repository }}/issues/$ISSUE" --jq .state) ||
|
|
371
|
+
! replies=$(gh api --paginate "repos/${{ github.repository }}/issues/$ISSUE/comments?since=$since" \
|
|
372
|
+
--jq ".[] | select(.user.login == \"$BOT\" and .created_at >= \"$since\") | .id"); then
|
|
373
|
+
echo "::warning::could not read #$ISSUE, so whether it was answered is unknown"
|
|
374
|
+
exit 0
|
|
375
|
+
fi
|
|
376
|
+
if [ "$state" = "closed" ] || [ -n "$replies" ]; then
|
|
377
|
+
exit 0
|
|
378
|
+
fi
|
|
379
|
+
echo "unanswered=true" >> "$GITHUB_OUTPUT"
|
|
380
|
+
echo "::error::the session ended without replying on #$ISSUE or closing it"
|
|
381
|
+
exit 1
|
|
382
|
+
|
|
344
383
|
# What this run was and what it cost, in the job summary and as an artifact with a stable
|
|
345
384
|
# name, which is what the portal's Runs screen and `roster doctor` read back. Never fatal:
|
|
346
385
|
# a missing record costs a row in a table, and failing the job over it would cost the run.
|
|
@@ -354,6 +393,7 @@ jobs:
|
|
|
354
393
|
MODEL: ${{ steps.agent.outputs.model || inputs.model }}
|
|
355
394
|
AGENT_OUTCOME: ${{ steps.session_action.outcome != 'skipped' && steps.session_action.outcome || steps.session_cli.outcome }}
|
|
356
395
|
JOB_STATUS: ${{ job.status }}
|
|
396
|
+
UNANSWERED: ${{ steps.answered.outputs.unanswered }}
|
|
357
397
|
RESULT_FILE: ${{ steps.session_action.outputs.execution_file || format('{0}/.roster-run/agent-result.json', github.workspace) }}
|
|
358
398
|
run: node roster-ops/run-record.mjs --out .roster-run/run.json
|
|
359
399
|
|
|
@@ -383,6 +423,7 @@ jobs:
|
|
|
383
423
|
BRAIN_DIR: ${{ steps.plan.outputs.brain_dir }}
|
|
384
424
|
BRAIN_REPO: ${{ steps.plan.outputs.brain_repo || github.repository }}
|
|
385
425
|
ISSUE: ${{ inputs.issue_number }}
|
|
426
|
+
UNANSWERED: ${{ steps.answered.outputs.unanswered }}
|
|
386
427
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
387
428
|
run: |
|
|
388
429
|
set -uo pipefail
|
|
@@ -402,6 +443,9 @@ jobs:
|
|
|
402
443
|
fi
|
|
403
444
|
|
|
404
445
|
body="This run did not finish, so there is no answer coming. [Run ${GITHUB_RUN_ID}]($RUN_URL) has the error."
|
|
446
|
+
if [ "$UNANSWERED" = "true" ]; then
|
|
447
|
+
body="This run stopped without replying here or closing the issue, so there is no answer coming. [Run ${GITHUB_RUN_ID}]($RUN_URL) shows where it stopped."
|
|
448
|
+
fi
|
|
405
449
|
if [ -n "$APP_TOKEN" ] && GH_TOKEN="$APP_TOKEN" gh issue comment "$issue" --repo "$BRAIN_REPO" --body "$body"; then
|
|
406
450
|
exit 0
|
|
407
451
|
fi
|
|
@@ -14,6 +14,10 @@ question instead of doing work has wasted its slot.
|
|
|
14
14
|
not stand still.
|
|
15
15
|
- **Never end a run blocked.** If everything on the list is genuinely blocked, do the most useful
|
|
16
16
|
unblocked thing you can find and say so in the report.
|
|
17
|
+
- **Finish inside the run.** Once you stop, the run is over: nothing will wake you, and anything
|
|
18
|
+
left running dies with it. Never end a turn to wait for a command; wait for it in the same call.
|
|
19
|
+
**Push your branch before a slow check** such as a full test suite, so the work survives if the
|
|
20
|
+
run is cut short, and push again once it passes.
|
|
17
21
|
|
|
18
22
|
## Choosing work
|
|
19
23
|
|
|
@@ -64,9 +64,11 @@ export function readResult(text) {
|
|
|
64
64
|
* What happened, in one word.
|
|
65
65
|
*
|
|
66
66
|
* The agent step's own outcome when it ran. When it never ran, the job's status says whether
|
|
67
|
-
* that was a cancel (a timeout is one) or a failure somewhere in the setup before it.
|
|
67
|
+
* that was a cancel (a timeout is one) or a failure somewhere in the setup before it. A mention
|
|
68
|
+
* the agent exited from cleanly without answering is not a success, whatever the agent says.
|
|
68
69
|
*/
|
|
69
|
-
export function outcomeOf(agentOutcome, jobStatus) {
|
|
70
|
+
export function outcomeOf(agentOutcome, jobStatus, unanswered = false) {
|
|
71
|
+
if (agentOutcome === "success" && unanswered) return "unanswered";
|
|
70
72
|
if (["success", "failure", "cancelled"].includes(agentOutcome)) return agentOutcome;
|
|
71
73
|
if (jobStatus === "cancelled") return "cancelled";
|
|
72
74
|
return "setup-failure";
|
|
@@ -79,7 +81,7 @@ export function buildRecord(env, resultText, now = Date.now()) {
|
|
|
79
81
|
v: 1,
|
|
80
82
|
staff: env.STAFF ?? "",
|
|
81
83
|
kind: env.KIND ?? "",
|
|
82
|
-
outcome: outcomeOf(env.AGENT_OUTCOME, env.JOB_STATUS),
|
|
84
|
+
outcome: outcomeOf(env.AGENT_OUTCOME, env.JOB_STATUS, env.UNANSWERED === "true"),
|
|
83
85
|
started: started ? new Date(started * 1000).toISOString() : null,
|
|
84
86
|
duration_s: started ? Math.max(0, Math.round(now / 1000 - started)) : null,
|
|
85
87
|
agent: env.AGENT_ID || null,
|
|
@@ -627,10 +627,11 @@ function inboxScreen(m, opts) {
|
|
|
627
627
|
status.textContent = msg ?? "";
|
|
628
628
|
status.className = "meta";
|
|
629
629
|
};
|
|
630
|
+
// busy(false) clears the status line, so it goes first: the other way round wiped the
|
|
631
|
+
// error as it was written, and a refused merge looked like nothing happening at all.
|
|
630
632
|
const failed = (e) => {
|
|
631
|
-
status.textContent = e.message;
|
|
632
|
-
status.className = "meta err";
|
|
633
633
|
busy(false);
|
|
634
|
+
status.textContent = e.message;
|
|
634
635
|
status.className = "meta err";
|
|
635
636
|
};
|
|
636
637
|
|