@bridge4dev/runner 0.11.0 → 0.22.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/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/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/git.d.ts +71 -0
- package/dist/git.js +207 -10
- package/dist/gitops.d.ts +489 -12
- package/dist/gitops.js +1717 -96
- package/dist/index.js +435 -32
- package/dist/paths.d.ts +26 -0
- package/dist/paths.js +34 -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 +7 -0
- package/dist/self-update.js +171 -23
- package/dist/service-unit.d.ts +79 -0
- package/dist/service-unit.js +211 -0
- package/dist/supervisor.d.ts +108 -1
- package/dist/supervisor.js +1010 -56
- 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 +2 -2
|
@@ -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
|
package/dist/git.d.ts
CHANGED
|
@@ -22,6 +22,26 @@ export declare function repoKeyFor(pathInsideRepo: string): Promise<string>;
|
|
|
22
22
|
export interface SessionWorktree {
|
|
23
23
|
branch: string;
|
|
24
24
|
worktreePath: string;
|
|
25
|
+
/** The branch this one forked from, when we created it just now. */
|
|
26
|
+
baseBranch?: string;
|
|
27
|
+
baseSha?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* What the API decided about this session's branch (session 13).
|
|
31
|
+
*
|
|
32
|
+
* Before this, the runner guessed: a branch that already existed was silently
|
|
33
|
+
* reused — so a second session on the same tickets inherited a stranger's
|
|
34
|
+
* commits — and a branch that did not was created off whatever the project
|
|
35
|
+
* folder was on. Both guesses are now errors, because both of them lose work
|
|
36
|
+
* without saying anything.
|
|
37
|
+
*/
|
|
38
|
+
export interface BranchPlan {
|
|
39
|
+
branch: string;
|
|
40
|
+
/** `NEW` — create it, and fail if it is already there. `CONTINUE` — the opposite. */
|
|
41
|
+
source: 'NEW' | 'CONTINUE';
|
|
42
|
+
/** Fork point for a NEW branch. `baseSha` wins when both are given. */
|
|
43
|
+
baseBranch?: string;
|
|
44
|
+
baseSha?: string;
|
|
25
45
|
}
|
|
26
46
|
/**
|
|
27
47
|
* Accept a branch name from the API only if git would accept it too. The value
|
|
@@ -37,7 +57,58 @@ export declare function sanitizeBranch(hint: string | undefined): string | null;
|
|
|
37
57
|
*/
|
|
38
58
|
export declare function ensureSessionWorktree(workspacePath: string, sessionId: string, branchHint?: string, options?: {
|
|
39
59
|
requireExistingBranch?: boolean;
|
|
60
|
+
plan?: BranchPlan;
|
|
40
61
|
}): Promise<SessionWorktree>;
|
|
62
|
+
/**
|
|
63
|
+
* DIRECT mode (session 16): the session's workplace IS the project folder.
|
|
64
|
+
*
|
|
65
|
+
* Nothing is created and nothing is moved. The folder stays on the branch it is
|
|
66
|
+
* on, and that branch is the session's branch — which is the whole point: the
|
|
67
|
+
* work the agent does is already where the person expects to find it, with no
|
|
68
|
+
* «Apply» step in between and nothing to lose if the session is never applied.
|
|
69
|
+
*
|
|
70
|
+
* The two refusals are both about NOT guessing:
|
|
71
|
+
*
|
|
72
|
+
* - not a git work tree — every git surface downstream would fail one call at
|
|
73
|
+
* a time instead of once, here, with a sentence that names the folder;
|
|
74
|
+
* - a detached HEAD — commits would land on no branch at all and be reachable
|
|
75
|
+
* only by sha. A person who checked out a tag to look at something must not
|
|
76
|
+
* discover an agent committed onto it.
|
|
77
|
+
*
|
|
78
|
+
* The path returned is the repository ROOT, not necessarily the folder that was
|
|
79
|
+
* configured. Every path in the Source Control panel is repo-root-relative
|
|
80
|
+
* because that is what `git status` prints, so the root is the only place the
|
|
81
|
+
* paths and the commands agree — and it is also the confinement root layer 1
|
|
82
|
+
* hands the agent.
|
|
83
|
+
*/
|
|
84
|
+
export declare function prepareDirectWorkspace(workspacePath: string): Promise<SessionWorktree>;
|
|
85
|
+
/**
|
|
86
|
+
* One preview checkout per repository, and never the project folder itself.
|
|
87
|
+
*
|
|
88
|
+
* The honest constraint behind «show me branch B while branch A is running»:
|
|
89
|
+
* the docker build context IS the project folder, so a rebuild there replaces
|
|
90
|
+
* the single running copy. A second worktree is the only way to have both — and
|
|
91
|
+
* the runner NEVER switches the branch in the project folder, because that
|
|
92
|
+
* silently moves the base, and the target of «Apply», for every session of the
|
|
93
|
+
* project at once.
|
|
94
|
+
*
|
|
95
|
+
* Checked out DETACHED at a sha rather than on the branch: git refuses to have
|
|
96
|
+
* one branch checked out twice, and a preview is a snapshot of a commit, not a
|
|
97
|
+
* place anybody commits.
|
|
98
|
+
*/
|
|
99
|
+
export declare function previewWorktreePath(workspaceKey: string): string;
|
|
100
|
+
export interface PreviewCheckout {
|
|
101
|
+
worktreePath: string;
|
|
102
|
+
branch: string;
|
|
103
|
+
sha: string;
|
|
104
|
+
}
|
|
105
|
+
export declare function ensurePreviewWorktree(input: {
|
|
106
|
+
workspacePath: string;
|
|
107
|
+
workspaceKey: string;
|
|
108
|
+
branch: string;
|
|
109
|
+
}): Promise<PreviewCheckout>;
|
|
110
|
+
/** Give the slot back. The branch is untouched — it was never checked out. */
|
|
111
|
+
export declare function removePreviewWorktree(workspaceKey: string): Promise<boolean>;
|
|
41
112
|
/**
|
|
42
113
|
* Drop a session branch after its worktree is gone. Only ever called when the
|
|
43
114
|
* API confirmed the work was already applied to the base branch — `-D` because
|