@bridge4dev/runner 0.13.1 → 0.26.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/dist/adapters/claude.d.ts +15 -7
- package/dist/adapters/claude.js +1024 -70
- package/dist/adapters/codex.d.ts +18 -3
- package/dist/adapters/codex.js +224 -65
- package/dist/adapters/questions.d.ts +42 -0
- package/dist/adapters/questions.js +86 -0
- package/dist/adapters/types.d.ts +200 -4
- package/dist/attachments.d.ts +8 -1
- package/dist/attachments.js +22 -4
- package/dist/auth-relay.d.ts +33 -3
- package/dist/auth-relay.js +199 -16
- package/dist/auto-resume.d.ts +18 -0
- package/dist/auto-resume.js +104 -0
- package/dist/commit-message.d.ts +51 -0
- package/dist/commit-message.js +224 -0
- package/dist/config.d.ts +29 -6
- package/dist/config.js +15 -0
- package/dist/crash-note.d.ts +54 -0
- package/dist/crash-note.js +105 -0
- package/dist/environment.d.ts +171 -0
- package/dist/environment.js +409 -0
- package/dist/git.d.ts +81 -0
- package/dist/git.js +301 -15
- package/dist/gitops.d.ts +489 -12
- package/dist/gitops.js +1717 -96
- package/dist/index.js +715 -8
- package/dist/paths.d.ts +35 -0
- package/dist/paths.js +45 -0
- package/dist/policy.d.ts +63 -0
- package/dist/policy.js +412 -10
- package/dist/protocol.d.ts +382 -60
- package/dist/protocol.js +104 -1
- package/dist/recipe-schema.d.ts +310 -0
- package/dist/recipe-schema.js +103 -0
- package/dist/recipe.d.ts +94 -0
- package/dist/recipe.js +238 -0
- package/dist/self-update.d.ts +21 -0
- package/dist/self-update.js +73 -1
- package/dist/service-unit.d.ts +61 -2
- package/dist/service-unit.js +150 -14
- package/dist/supervisor.d.ts +108 -1
- package/dist/supervisor.js +1045 -57
- package/dist/verify-queue.d.ts +17 -0
- package/dist/verify-queue.js +100 -0
- package/dist/verify.d.ts +203 -0
- package/dist/verify.js +788 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { stateDir } from './paths.js';
|
|
4
|
+
/**
|
|
5
|
+
* How often a session is allowed to have its interrupted turn picked up again
|
|
6
|
+
* automatically.
|
|
7
|
+
*
|
|
8
|
+
* Session 18 (QA-112). When the runner process dies mid-turn, everything the
|
|
9
|
+
* agent was doing is abandoned and the session is parked with «send a message to
|
|
10
|
+
* continue». On 2026-07-30 that cost the owner two full runs of
|
|
11
|
+
* `timeout 900 pnpm -r typecheck`: the process died, they typed «продолжай», and
|
|
12
|
+
* the fifteen-minute command started from zero — twice, four minutes apart.
|
|
13
|
+
*
|
|
14
|
+
* Continuing by itself is only safe with a ceiling. A runner stuck in a crash
|
|
15
|
+
* loop would otherwise relaunch the same agent on every restart, five seconds
|
|
16
|
+
* apart, forever — and each relaunch costs real tokens and may repeat a
|
|
17
|
+
* side effect. So: at most `MAX_ATTEMPTS` inside `WINDOW_MS`, per session,
|
|
18
|
+
* recorded on disk because the whole point is that the process does not survive.
|
|
19
|
+
*/
|
|
20
|
+
const MAX_ATTEMPTS = 2;
|
|
21
|
+
const WINDOW_MS = 30 * 60_000;
|
|
22
|
+
function ledgerPath() {
|
|
23
|
+
return path.join(stateDir(), 'auto-resume.json');
|
|
24
|
+
}
|
|
25
|
+
function read() {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(fs.readFileSync(ledgerPath(), 'utf8'));
|
|
28
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
29
|
+
return {};
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function write(ledger) {
|
|
37
|
+
try {
|
|
38
|
+
const dir = stateDir();
|
|
39
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
40
|
+
const tmp = path.join(dir, `.auto-resume.json.${process.pid}.tmp`);
|
|
41
|
+
fs.writeFileSync(tmp, JSON.stringify(ledger), { mode: 0o600 });
|
|
42
|
+
fs.renameSync(tmp, ledgerPath());
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
/* Failing to record an attempt must not stop the session from continuing:
|
|
46
|
+
the ceiling is a safety net, not a precondition. */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* May this session's interrupted turn be picked up automatically right now?
|
|
51
|
+
*
|
|
52
|
+
* Records the attempt when it says yes — the caller does not have to remember to,
|
|
53
|
+
* and a caller that forgot would silently remove the ceiling.
|
|
54
|
+
*/
|
|
55
|
+
export function claimAutoResume(sessionId, now = Date.now()) {
|
|
56
|
+
const ledger = read();
|
|
57
|
+
const recent = (ledger[sessionId]?.at ?? []).filter((at) => now - at < WINDOW_MS);
|
|
58
|
+
if (recent.length >= MAX_ATTEMPTS) {
|
|
59
|
+
// Keep the pruned list so the window slides instead of being reset by a
|
|
60
|
+
// refusal, then let the caller fall back to asking the human.
|
|
61
|
+
ledger[sessionId] = { at: recent };
|
|
62
|
+
write(ledger);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
ledger[sessionId] = { at: [...recent, now] };
|
|
66
|
+
write(ledger);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Forget a session's history — called when a HUMAN sends a message.
|
|
71
|
+
*
|
|
72
|
+
* Without this, two automatic continuations early in a long session would use up
|
|
73
|
+
* the allowance for the rest of its life. A person typing into the session is the
|
|
74
|
+
* clearest possible signal that the work is on track again.
|
|
75
|
+
*/
|
|
76
|
+
export function clearAutoResume(sessionId) {
|
|
77
|
+
const ledger = read();
|
|
78
|
+
if (!(sessionId in ledger))
|
|
79
|
+
return;
|
|
80
|
+
// Rebuilt rather than `delete`d: the ledger is a plain record keyed by session
|
|
81
|
+
// id, and a dynamic delete on it is exactly the pattern the lint rule is for.
|
|
82
|
+
write(Object.fromEntries(Object.entries(ledger).filter(([id]) => id !== sessionId)));
|
|
83
|
+
}
|
|
84
|
+
/** Drop entries for sessions that no longer exist, and anything past the window. */
|
|
85
|
+
export function pruneAutoResume(liveSessionIds, now = Date.now()) {
|
|
86
|
+
const ledger = read();
|
|
87
|
+
const kept = {};
|
|
88
|
+
let changed = false;
|
|
89
|
+
for (const [sessionId, entry] of Object.entries(ledger)) {
|
|
90
|
+
const recent = entry.at.filter((at) => now - at < WINDOW_MS);
|
|
91
|
+
// Nothing recent AND the session is gone → forget it entirely. A live
|
|
92
|
+
// session keeps its (possibly empty) entry so its budget is honest.
|
|
93
|
+
if (recent.length === 0 && !liveSessionIds.has(sessionId)) {
|
|
94
|
+
changed = true;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (recent.length !== entry.at.length)
|
|
98
|
+
changed = true;
|
|
99
|
+
kept[sessionId] = { at: recent };
|
|
100
|
+
}
|
|
101
|
+
if (changed)
|
|
102
|
+
write(kept);
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=auto-resume.js.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
export interface ProposeCommitMessageInput {
|
|
3
|
+
worktreePath: string;
|
|
4
|
+
workspacePath: string;
|
|
5
|
+
branch: string;
|
|
6
|
+
baseBranch?: string;
|
|
7
|
+
/** Ticket numbers to reference; the API substitutes them, never the model. */
|
|
8
|
+
tickets?: number[];
|
|
9
|
+
/** Subjects of the commits already on the branch, newest first. */
|
|
10
|
+
commitSubjects?: string[];
|
|
11
|
+
/** Project settings, straight from the workspace row. */
|
|
12
|
+
language?: string;
|
|
13
|
+
convention?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ProposeCommitMessageResult {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
message?: string;
|
|
18
|
+
/** Files removed from the prompt because they are on the secret denylist. */
|
|
19
|
+
redactedFiles?: number;
|
|
20
|
+
diffTruncated?: boolean;
|
|
21
|
+
error?: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Drop whole file sections that are on the secret denylist.
|
|
25
|
+
*
|
|
26
|
+
* `gitDiff` throws in this situation; here that would mean «no proposal at all
|
|
27
|
+
* because the branch touches `.env.example`». Removing the file and saying so
|
|
28
|
+
* is both safer (the bytes never leave) and more useful.
|
|
29
|
+
*/
|
|
30
|
+
export declare function stripProtectedFiles(diff: string): {
|
|
31
|
+
diff: string;
|
|
32
|
+
redacted: number;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* The one-shot itself.
|
|
36
|
+
*
|
|
37
|
+
* `maxTurns: 1` and every tool refused: this run must read nothing, write
|
|
38
|
+
* nothing and call nothing. It gets the diff in its prompt and answers with
|
|
39
|
+
* text. `settingSources: []` for the same reason the session adapter uses it —
|
|
40
|
+
* a `.claude/settings.json` inside the worktree is a file the agent can write.
|
|
41
|
+
*/
|
|
42
|
+
export declare function proposeCommitMessage(input: ProposeCommitMessageInput, queryFn?: typeof query): Promise<ProposeCommitMessageResult>;
|
|
43
|
+
/**
|
|
44
|
+
* What comes back is text a model wrote, so it is treated as such: fences off,
|
|
45
|
+
* an opening «Here is the commit message:» off, masked, capped.
|
|
46
|
+
*
|
|
47
|
+
* The trailer and signature filtering lives on the API side, where it also
|
|
48
|
+
* catches what a human pastes into the box — one filter, one place.
|
|
49
|
+
*/
|
|
50
|
+
export declare function cleanProposal(text: string): string;
|
|
51
|
+
//# sourceMappingURL=commit-message.d.ts.map
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
2
|
+
import { scrubbedEnv } from './adapters/claude.js';
|
|
3
|
+
import { gitBranchDiff } from './gitops.js';
|
|
4
|
+
import { isSecretPath, maskString } from './policy.js';
|
|
5
|
+
/**
|
|
6
|
+
* «Propose a commit message» — level 1 (session 14).
|
|
7
|
+
*
|
|
8
|
+
* A ONE-SHOT run, not a message into the live session. Sending it to the open
|
|
9
|
+
* session would cost three things at once: the feed fills with a request nobody
|
|
10
|
+
* made, the session's context window pays for the whole diff, and the call
|
|
11
|
+
* collides with the worktree lock the agent is already holding mid-turn.
|
|
12
|
+
*
|
|
13
|
+
* It always uses the Claude SDK, whichever agent the session runs. The SDK
|
|
14
|
+
* ships its own CLI, so it is present on every runner — a Codex session gets a
|
|
15
|
+
* proposal too, instead of the button being greyed out on half the machines.
|
|
16
|
+
*
|
|
17
|
+
* The diff is masked and stripped of protected files BEFORE it becomes prompt
|
|
18
|
+
* text. A model prompt is an outbound channel exactly like the Files panel, and
|
|
19
|
+
* QA-105 is the reminder that the two have to be held to the same rule.
|
|
20
|
+
*/
|
|
21
|
+
/** How much diff is worth a commit message. Beyond this it is a code review. */
|
|
22
|
+
const DIFF_PROMPT_CAP = 60_000;
|
|
23
|
+
/** The whole answer is one commit message; anything longer is a monologue. */
|
|
24
|
+
const MESSAGE_CAP = 4_000;
|
|
25
|
+
/** A model that has not answered by now is not going to. */
|
|
26
|
+
const PROPOSE_TIMEOUT_MS = 90_000;
|
|
27
|
+
/**
|
|
28
|
+
* Drop whole file sections that are on the secret denylist.
|
|
29
|
+
*
|
|
30
|
+
* `gitDiff` throws in this situation; here that would mean «no proposal at all
|
|
31
|
+
* because the branch touches `.env.example`». Removing the file and saying so
|
|
32
|
+
* is both safer (the bytes never leave) and more useful.
|
|
33
|
+
*/
|
|
34
|
+
export function stripProtectedFiles(diff) {
|
|
35
|
+
const sections = diff.split(/(?=^diff --git )/m);
|
|
36
|
+
let redacted = 0;
|
|
37
|
+
const kept = sections.filter((section) => {
|
|
38
|
+
// The first section of a diff is whatever came before the first header —
|
|
39
|
+
// usually empty. It carries no file, so there is nothing to protect.
|
|
40
|
+
if (!/^diff --git /.test(section))
|
|
41
|
+
return true;
|
|
42
|
+
const paths = diffHeaderPaths(section);
|
|
43
|
+
// Fail CLOSED. A header shape we cannot read is a file we cannot check,
|
|
44
|
+
// and the two outcomes are not comparable: dropping it costs a slightly
|
|
45
|
+
// less informed commit message, keeping it puts an unexamined file into a
|
|
46
|
+
// prompt that leaves the machine. Session 15 found this with the shape
|
|
47
|
+
// that actually occurs — git QUOTES a header whose path contains `"`, a
|
|
48
|
+
// backslash, a tab or a newline (`diff --git "a/we\"ird/.env" …`), and
|
|
49
|
+
// `core.quotePath=false` does not turn that off. It only suppresses the
|
|
50
|
+
// octal escaping of non-ASCII.
|
|
51
|
+
if (!paths) {
|
|
52
|
+
redacted += 1;
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (paths.some(isSecretPath)) {
|
|
56
|
+
redacted += 1;
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
});
|
|
61
|
+
return { diff: kept.join(''), redacted };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The `a/` and `b/` paths out of a `diff --git` header, quoted or not.
|
|
65
|
+
*
|
|
66
|
+
* Git writes the quoted form whenever a path contains a character it cannot
|
|
67
|
+
* put in the line as-is, and escapes it the way C does. Unescaping is enough
|
|
68
|
+
* here: these strings are only ever compared against the secret-path denylist,
|
|
69
|
+
* never used to open anything.
|
|
70
|
+
*/
|
|
71
|
+
function diffHeaderPaths(section) {
|
|
72
|
+
const line = section.split('\n', 1)[0] ?? '';
|
|
73
|
+
const plain = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
|
|
74
|
+
if (plain)
|
|
75
|
+
return [plain[1] ?? '', plain[2] ?? ''];
|
|
76
|
+
const quoted = /^diff --git "a\/((?:[^"\\]|\\.)*)" "b\/((?:[^"\\]|\\.)*)"$/.exec(line);
|
|
77
|
+
if (quoted)
|
|
78
|
+
return [unescapeGitPath(quoted[1] ?? ''), unescapeGitPath(quoted[2] ?? '')];
|
|
79
|
+
// One side quoted and the other not — git does not write that, and a header
|
|
80
|
+
// we cannot account for is one the caller must treat as unknown.
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
function unescapeGitPath(value) {
|
|
84
|
+
return value.replace(/\\([0-7]{3}|.)/g, (_match, escaped) => {
|
|
85
|
+
if (/^[0-7]{3}$/.test(escaped))
|
|
86
|
+
return String.fromCharCode(parseInt(escaped, 8));
|
|
87
|
+
const simple = { n: '\n', t: '\t', r: '\r', '\\': '\\', '"': '"' };
|
|
88
|
+
return simple[escaped] ?? escaped;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function buildPrompt(input, diff) {
|
|
92
|
+
const language = input.language?.trim() || 'English';
|
|
93
|
+
const convention = input.convention?.trim() ||
|
|
94
|
+
'Conventional Commits: `type(scope): subject`, where type is one of feat/fix/docs/refactor/test/chore/perf/build/ci.';
|
|
95
|
+
const commits = (input.commitSubjects ?? []).slice(0, 20);
|
|
96
|
+
return [
|
|
97
|
+
'Write ONE git commit message for the change below. Output the message and nothing else —',
|
|
98
|
+
'no preamble, no explanation, no code fences.',
|
|
99
|
+
'',
|
|
100
|
+
'Rules:',
|
|
101
|
+
`- Language: ${language}.`,
|
|
102
|
+
`- Convention: ${convention}`,
|
|
103
|
+
'- The subject line is at most 72 characters and does not end with a period.',
|
|
104
|
+
'- After the subject: a blank line, then 1–3 sentences on what changed and why, then a blank',
|
|
105
|
+
' line and up to 5 bullets of the notable points. Skip the bullets if there is nothing to say.',
|
|
106
|
+
'- Describe what the diff actually does. Do not speculate about intent you cannot see.',
|
|
107
|
+
'- Do NOT add ticket references, `Co-Authored-By`, `Generated with`, emoji or any trailer —',
|
|
108
|
+
' DevBridge appends the trailers itself and strips anything else.',
|
|
109
|
+
'',
|
|
110
|
+
`Branch: ${input.branch}${input.baseBranch ? ` (forked from ${input.baseBranch})` : ''}`,
|
|
111
|
+
...(commits.length
|
|
112
|
+
? ['', 'Commits already on this branch, newest first:', ...commits.map((s) => `- ${s}`)]
|
|
113
|
+
: []),
|
|
114
|
+
'',
|
|
115
|
+
'Diff:',
|
|
116
|
+
'```diff',
|
|
117
|
+
diff,
|
|
118
|
+
'```',
|
|
119
|
+
].join('\n');
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The one-shot itself.
|
|
123
|
+
*
|
|
124
|
+
* `maxTurns: 1` and every tool refused: this run must read nothing, write
|
|
125
|
+
* nothing and call nothing. It gets the diff in its prompt and answers with
|
|
126
|
+
* text. `settingSources: []` for the same reason the session adapter uses it —
|
|
127
|
+
* a `.claude/settings.json` inside the worktree is a file the agent can write.
|
|
128
|
+
*/
|
|
129
|
+
export async function proposeCommitMessage(input, queryFn = query) {
|
|
130
|
+
const raw = await gitBranchDiff({
|
|
131
|
+
worktreePath: input.worktreePath,
|
|
132
|
+
workspacePath: input.workspacePath,
|
|
133
|
+
sessionBranch: input.branch,
|
|
134
|
+
...(input.baseBranch ? { baseBranch: input.baseBranch } : {}),
|
|
135
|
+
maxBytes: DIFF_PROMPT_CAP,
|
|
136
|
+
}).catch(() => null);
|
|
137
|
+
if (!raw)
|
|
138
|
+
return { ok: false, error: 'Could not read the diff of this branch' };
|
|
139
|
+
const { diff, redacted } = stripProtectedFiles(raw.diff);
|
|
140
|
+
if (!diff.trim()) {
|
|
141
|
+
return {
|
|
142
|
+
ok: false,
|
|
143
|
+
error: redacted > 0
|
|
144
|
+
? 'Everything this branch changed is on the protected-paths list — nothing can be described'
|
|
145
|
+
: 'There is nothing to describe yet — this branch matches its base branch',
|
|
146
|
+
redactedFiles: redacted,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
let text = '';
|
|
150
|
+
try {
|
|
151
|
+
const run = queryFn({
|
|
152
|
+
prompt: buildPrompt(input, diff),
|
|
153
|
+
options: {
|
|
154
|
+
cwd: input.worktreePath,
|
|
155
|
+
env: scrubbedEnv(),
|
|
156
|
+
settingSources: [],
|
|
157
|
+
maxTurns: 1,
|
|
158
|
+
// Belt and braces: an empty allowlist is a statement of intent, the
|
|
159
|
+
// callback is the mechanism. Both, because this run has no human
|
|
160
|
+
// attached to answer a permission prompt.
|
|
161
|
+
allowedTools: [],
|
|
162
|
+
canUseTool: () => Promise.resolve({
|
|
163
|
+
behavior: 'deny',
|
|
164
|
+
message: 'This run only writes a commit message — it may not use tools.',
|
|
165
|
+
}),
|
|
166
|
+
systemPrompt: {
|
|
167
|
+
type: 'preset',
|
|
168
|
+
preset: 'claude_code',
|
|
169
|
+
append: 'You are writing a single git commit message for DevBridge. Answer with the message text only.',
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
const deadline = setTimeout(() => void run.interrupt?.().catch(() => undefined), PROPOSE_TIMEOUT_MS);
|
|
174
|
+
deadline.unref();
|
|
175
|
+
try {
|
|
176
|
+
for await (const message of run) {
|
|
177
|
+
if (message.type !== 'assistant')
|
|
178
|
+
continue;
|
|
179
|
+
for (const block of message.message.content) {
|
|
180
|
+
if (block.type === 'text')
|
|
181
|
+
text += block.text;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
finally {
|
|
186
|
+
clearTimeout(deadline);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
error: maskString(String(error instanceof Error ? error.message : error)).slice(0, 300),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const message = cleanProposal(text);
|
|
196
|
+
if (!message)
|
|
197
|
+
return { ok: false, error: 'The agent returned nothing usable' };
|
|
198
|
+
return {
|
|
199
|
+
ok: true,
|
|
200
|
+
message,
|
|
201
|
+
...(redacted > 0 ? { redactedFiles: redacted } : {}),
|
|
202
|
+
...(raw.truncated ? { diffTruncated: true } : {}),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* What comes back is text a model wrote, so it is treated as such: fences off,
|
|
207
|
+
* an opening «Here is the commit message:» off, masked, capped.
|
|
208
|
+
*
|
|
209
|
+
* The trailer and signature filtering lives on the API side, where it also
|
|
210
|
+
* catches what a human pastes into the box — one filter, one place.
|
|
211
|
+
*/
|
|
212
|
+
export function cleanProposal(text) {
|
|
213
|
+
let value = maskString(text).trim();
|
|
214
|
+
const fenced = /^```(?:[a-z]*)\n([\s\S]*?)\n?```$/i.exec(value);
|
|
215
|
+
if (fenced?.[1])
|
|
216
|
+
value = fenced[1].trim();
|
|
217
|
+
// `here(?:'s| is)` — with the apostrophe attached, not spaced. «Here's the
|
|
218
|
+
// commit message:» is the commonest preamble a model writes and the original
|
|
219
|
+
// `here (?:is|'s)` required a space before it, so that one form survived into
|
|
220
|
+
// the box (session 15).
|
|
221
|
+
value = value.replace(/^(?:here(?:'s| is)[^\n:]*:|commit message:)\s*\n+/i, '').trim();
|
|
222
|
+
return value.slice(0, MESSAGE_CAP);
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=commit-message.js.map
|
package/dist/config.d.ts
CHANGED
|
@@ -47,6 +47,23 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
47
47
|
}, {
|
|
48
48
|
max_sessions: number;
|
|
49
49
|
}>>;
|
|
50
|
+
/**
|
|
51
|
+
* Session 14: the machine owner's veto over running project recipes.
|
|
52
|
+
*
|
|
53
|
+
* Verification executes commands a DevBridge manager approved — a build, a
|
|
54
|
+
* test run, possibly a deploy. That is a real execution channel on somebody
|
|
55
|
+
* else's server, and the person who owns the server gets the last word on
|
|
56
|
+
* whether it exists at all. `enabled = false` here means the capability is
|
|
57
|
+
* not even announced, so the dashboard never draws the card: a control that
|
|
58
|
+
* is switched off must not look like a control that is broken.
|
|
59
|
+
*/
|
|
60
|
+
verify: z.ZodOptional<z.ZodObject<{
|
|
61
|
+
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
62
|
+
}, "strip", z.ZodTypeAny, {
|
|
63
|
+
enabled: boolean;
|
|
64
|
+
}, {
|
|
65
|
+
enabled?: boolean | undefined;
|
|
66
|
+
}>>;
|
|
50
67
|
}, "strip", z.ZodTypeAny, {
|
|
51
68
|
api: {
|
|
52
69
|
url: string;
|
|
@@ -57,16 +74,19 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
57
74
|
name: string;
|
|
58
75
|
token: string;
|
|
59
76
|
};
|
|
60
|
-
codex?: {
|
|
61
|
-
auth: "link" | "own";
|
|
62
|
-
} | undefined;
|
|
63
77
|
mcp?: {
|
|
64
78
|
url: string;
|
|
65
79
|
token: string;
|
|
66
80
|
} | undefined;
|
|
81
|
+
codex?: {
|
|
82
|
+
auth: "link" | "own";
|
|
83
|
+
} | undefined;
|
|
67
84
|
limits?: {
|
|
68
85
|
max_sessions: number;
|
|
69
86
|
} | undefined;
|
|
87
|
+
verify?: {
|
|
88
|
+
enabled: boolean;
|
|
89
|
+
} | undefined;
|
|
70
90
|
}, {
|
|
71
91
|
api: {
|
|
72
92
|
url: string;
|
|
@@ -77,16 +97,19 @@ declare const ConfigSchema: z.ZodObject<{
|
|
|
77
97
|
name: string;
|
|
78
98
|
token: string;
|
|
79
99
|
};
|
|
80
|
-
codex?: {
|
|
81
|
-
auth?: "link" | "own" | undefined;
|
|
82
|
-
} | undefined;
|
|
83
100
|
mcp?: {
|
|
84
101
|
url: string;
|
|
85
102
|
token: string;
|
|
86
103
|
} | undefined;
|
|
104
|
+
codex?: {
|
|
105
|
+
auth?: "link" | "own" | undefined;
|
|
106
|
+
} | undefined;
|
|
87
107
|
limits?: {
|
|
88
108
|
max_sessions: number;
|
|
89
109
|
} | undefined;
|
|
110
|
+
verify?: {
|
|
111
|
+
enabled?: boolean | undefined;
|
|
112
|
+
} | undefined;
|
|
90
113
|
}>;
|
|
91
114
|
export type RunnerConfig = z.infer<typeof ConfigSchema>;
|
|
92
115
|
export declare function loadConfig(): RunnerConfig | null;
|
package/dist/config.js
CHANGED
|
@@ -44,6 +44,21 @@ const ConfigSchema = z.object({
|
|
|
44
44
|
max_sessions: z.number().int().min(1).max(64),
|
|
45
45
|
})
|
|
46
46
|
.optional(),
|
|
47
|
+
/**
|
|
48
|
+
* Session 14: the machine owner's veto over running project recipes.
|
|
49
|
+
*
|
|
50
|
+
* Verification executes commands a DevBridge manager approved — a build, a
|
|
51
|
+
* test run, possibly a deploy. That is a real execution channel on somebody
|
|
52
|
+
* else's server, and the person who owns the server gets the last word on
|
|
53
|
+
* whether it exists at all. `enabled = false` here means the capability is
|
|
54
|
+
* not even announced, so the dashboard never draws the card: a control that
|
|
55
|
+
* is switched off must not look like a control that is broken.
|
|
56
|
+
*/
|
|
57
|
+
verify: z
|
|
58
|
+
.object({
|
|
59
|
+
enabled: z.boolean().default(true),
|
|
60
|
+
})
|
|
61
|
+
.optional(),
|
|
47
62
|
});
|
|
48
63
|
export function loadConfig() {
|
|
49
64
|
const file = configFilePath();
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why the previous runner process died — written by the dying process, read once
|
|
3
|
+
* by its successor, reported in `hello`.
|
|
4
|
+
*
|
|
5
|
+
* The 2026-07-30 incident (QA-112) could not be classified after the fact: three
|
|
6
|
+
* live sessions were parked with «Runner reconnected», and the only evidence the
|
|
7
|
+
* daemon had even restarted was that its in-memory session map was empty. The
|
|
8
|
+
* process itself left nothing, and the machine's journal is unreachable from
|
|
9
|
+
* DevBridge. This file closes that gap for the two causes that matter:
|
|
10
|
+
*
|
|
11
|
+
* - an unhandled error, which the process can describe itself;
|
|
12
|
+
* - a kernel OOM kill, which it cannot — the note is absent and the cgroup's
|
|
13
|
+
* `memory.events` counter is read instead (`readOomKills`).
|
|
14
|
+
*
|
|
15
|
+
* "Absent note + oom_kill went up" is therefore a positive OOM diagnosis rather
|
|
16
|
+
* than a shrug, which is exactly the distinction the incident needed.
|
|
17
|
+
*/
|
|
18
|
+
export interface CrashNote {
|
|
19
|
+
kind: string;
|
|
20
|
+
detail: string;
|
|
21
|
+
at: string;
|
|
22
|
+
version: string;
|
|
23
|
+
activeSessionIds: string[];
|
|
24
|
+
}
|
|
25
|
+
/** What the successor reports upstream. `kind: 'oom'` is inferred, not written. */
|
|
26
|
+
export interface LastExit {
|
|
27
|
+
kind: string;
|
|
28
|
+
at?: string;
|
|
29
|
+
detail?: string;
|
|
30
|
+
version?: string;
|
|
31
|
+
/** Sessions the dead process was running — how much the death actually cost. */
|
|
32
|
+
activeSessionIds?: string[];
|
|
33
|
+
/** Cumulative `oom_kill` from the service cgroup, when readable. */
|
|
34
|
+
oomKills?: number;
|
|
35
|
+
}
|
|
36
|
+
export declare function recordCrash(note: CrashNote): void;
|
|
37
|
+
/**
|
|
38
|
+
* `oom_kill` from the cgroup this process lives in.
|
|
39
|
+
*
|
|
40
|
+
* cgroup v2 exposes it in `memory.events` of the service slice. The counter is
|
|
41
|
+
* cumulative for the cgroup's lifetime, and a systemd restart does NOT reset it
|
|
42
|
+
* (the cgroup outlives the process), so the interesting quantity is the delta —
|
|
43
|
+
* hence the small sidecar file.
|
|
44
|
+
*/
|
|
45
|
+
export declare function readOomKills(readFile?: (p: string) => string): number | null;
|
|
46
|
+
/**
|
|
47
|
+
* Read and CONSUME the note left by the previous process.
|
|
48
|
+
*
|
|
49
|
+
* Consuming matters: without it every reconnect for the rest of the daemon's
|
|
50
|
+
* life would keep reporting a death that happened once, and the dashboard would
|
|
51
|
+
* show a permanent crash badge on a healthy server.
|
|
52
|
+
*/
|
|
53
|
+
export declare function takeLastExit(readOom?: () => number | null): LastExit | null;
|
|
54
|
+
//# sourceMappingURL=crash-note.d.ts.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { stateDir } from './paths.js';
|
|
4
|
+
function crashNotePath() {
|
|
5
|
+
return path.join(stateDir(), 'crash.json');
|
|
6
|
+
}
|
|
7
|
+
function oomCounterPath() {
|
|
8
|
+
return path.join(stateDir(), 'oom-kills');
|
|
9
|
+
}
|
|
10
|
+
export function recordCrash(note) {
|
|
11
|
+
const dir = stateDir();
|
|
12
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
13
|
+
// Written in place, not atomically: a rename needs a second syscall and this
|
|
14
|
+
// runs on a process that is already failing. A truncated note still carries
|
|
15
|
+
// its `kind`, and a missing one is handled as "unknown" upstream.
|
|
16
|
+
fs.writeFileSync(crashNotePath(), JSON.stringify(note), { mode: 0o600 });
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* `oom_kill` from the cgroup this process lives in.
|
|
20
|
+
*
|
|
21
|
+
* cgroup v2 exposes it in `memory.events` of the service slice. The counter is
|
|
22
|
+
* cumulative for the cgroup's lifetime, and a systemd restart does NOT reset it
|
|
23
|
+
* (the cgroup outlives the process), so the interesting quantity is the delta —
|
|
24
|
+
* hence the small sidecar file.
|
|
25
|
+
*/
|
|
26
|
+
export function readOomKills(readFile = (p) => fs.readFileSync(p, 'utf8')) {
|
|
27
|
+
try {
|
|
28
|
+
// `/proc/self/cgroup` on v2 is a single line: `0::/user.slice/.../x.service`
|
|
29
|
+
const rel = readFile('/proc/self/cgroup')
|
|
30
|
+
.split('\n')
|
|
31
|
+
.map((line) => line.split(':'))
|
|
32
|
+
.find((parts) => parts[0] === '0' && parts[1] === '')?.[2];
|
|
33
|
+
if (!rel)
|
|
34
|
+
return null;
|
|
35
|
+
const events = readFile(path.join('/sys/fs/cgroup', rel, 'memory.events'));
|
|
36
|
+
const line = events.split('\n').find((l) => l.startsWith('oom_kill '));
|
|
37
|
+
if (!line)
|
|
38
|
+
return null;
|
|
39
|
+
const value = Number.parseInt(line.slice('oom_kill '.length).trim(), 10);
|
|
40
|
+
return Number.isFinite(value) ? value : null;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Read and CONSUME the note left by the previous process.
|
|
48
|
+
*
|
|
49
|
+
* Consuming matters: without it every reconnect for the rest of the daemon's
|
|
50
|
+
* life would keep reporting a death that happened once, and the dashboard would
|
|
51
|
+
* show a permanent crash badge on a healthy server.
|
|
52
|
+
*/
|
|
53
|
+
export function takeLastExit(readOom = readOomKills) {
|
|
54
|
+
let note;
|
|
55
|
+
try {
|
|
56
|
+
note = JSON.parse(fs.readFileSync(crashNotePath(), 'utf8'));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
note = null;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
fs.rmSync(crashNotePath(), { force: true });
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* a note we cannot delete is better than refusing to start */
|
|
66
|
+
}
|
|
67
|
+
const oomKills = readOom();
|
|
68
|
+
let previousOomKills;
|
|
69
|
+
try {
|
|
70
|
+
previousOomKills = Number.parseInt(fs.readFileSync(oomCounterPath(), 'utf8').trim(), 10);
|
|
71
|
+
if (!Number.isFinite(previousOomKills))
|
|
72
|
+
previousOomKills = null;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
previousOomKills = null;
|
|
76
|
+
}
|
|
77
|
+
if (oomKills !== null) {
|
|
78
|
+
try {
|
|
79
|
+
fs.mkdirSync(stateDir(), { recursive: true, mode: 0o700 });
|
|
80
|
+
fs.writeFileSync(oomCounterPath(), String(oomKills), { mode: 0o600 });
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* the absolute count is still reported below */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const oomSinceLastStart = oomKills !== null && previousOomKills !== null ? oomKills - previousOomKills : null;
|
|
87
|
+
if (note) {
|
|
88
|
+
return {
|
|
89
|
+
kind: note.kind,
|
|
90
|
+
at: note.at,
|
|
91
|
+
detail: note.detail,
|
|
92
|
+
version: note.version,
|
|
93
|
+
activeSessionIds: note.activeSessionIds,
|
|
94
|
+
...(oomKills === null ? {} : { oomKills }),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// No note. If the cgroup's OOM counter moved since we last looked, the kernel
|
|
98
|
+
// killed something in this service — the honest answer the incident lacked.
|
|
99
|
+
if (oomSinceLastStart !== null && oomSinceLastStart > 0) {
|
|
100
|
+
return { kind: 'oom', oomKills: oomKills ?? undefined };
|
|
101
|
+
}
|
|
102
|
+
// First ever start, a clean stop, or a SIGKILL from outside. Nothing to claim.
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=crash-note.js.map
|