bareloop 0.8.0 → 0.9.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/CHANGELOG.md +421 -0
- package/README.md +10 -3
- package/bareloop.context.md +198 -17
- package/package.json +1 -1
- package/src/authorflow.js +1280 -0
- package/src/authoring.js +1296 -0
- package/src/authorjob.js +793 -0
- package/src/authorscout.js +706 -0
- package/src/bridges.js +2 -2
- package/src/declaredclose.js +519 -0
- package/src/index.js +72 -0
- package/src/job.js +48 -4
- package/src/kinds.js +1354 -0
- package/src/ledger.js +13 -0
- package/src/plan.js +26 -1
- package/src/planrun.js +239 -16
- package/src/ralph.js +19 -4
- package/src/reuse.js +68 -7
- package/src/root.js +8 -1
- package/src/run.js +10 -3
- package/src/text.js +120 -0
- package/src/workbranch.js +261 -0
- package/types/authorflow.d.ts +720 -0
- package/types/authoring.d.ts +447 -0
- package/types/authorjob.d.ts +241 -0
- package/types/authorscout.d.ts +307 -0
- package/types/declaredclose.d.ts +174 -0
- package/types/index.d.ts +9 -1
- package/types/job.d.ts +1 -0
- package/types/kinds.d.ts +343 -0
- package/types/plan.d.ts +18 -0
- package/types/planrun.d.ts +10 -3
- package/types/ralph.d.ts +1 -0
- package/types/run.d.ts +10 -2
- package/types/text.d.ts +84 -0
- package/types/workbranch.d.ts +102 -0
package/src/root.js
CHANGED
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
// UNRELIABLE red-set for comparison. Imported (never re-spelled) so the detector
|
|
31
31
|
// keys off the exact string the shell emits — a magic string here would drift.
|
|
32
32
|
import { GAP_KEEP_TRIM_MARKER } from './ralph.js';
|
|
33
|
+
import { GAP_TRIM_MARKER } from './kinds.js';
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* Strip a trailing duration stamp from a kept-failure line. Real `node --test`
|
|
@@ -107,7 +108,13 @@ export function createRoot({ gapKeep, redact = (s) => s, writesInformative = tru
|
|
|
107
108
|
*/
|
|
108
109
|
const keptSet = (gap, pattern) => {
|
|
109
110
|
const re = pattern ? new RegExp(pattern, 'm') : keepRe;
|
|
110
|
-
|
|
111
|
+
// BOTH trim announcements, because there are now two gap renderers: ralph's
|
|
112
|
+
// keep-block cap on a command close, and the kind executor's per-stage line
|
|
113
|
+
// cap on a DECLARED one. A trim the detector cannot see is worse than a
|
|
114
|
+
// missing detector — it makes the strong "reds unchanged" claim off a window
|
|
115
|
+
// that silently dropped failures (finding 5), which is the direction that
|
|
116
|
+
// manufactures a fixation reading out of an instrument that went blind.
|
|
117
|
+
if (!re || gap.includes(GAP_KEEP_TRIM_MARKER) || gap.includes(GAP_TRIM_MARKER)) return null;
|
|
111
118
|
const lines = [...new Set(gap.split('\n').filter((l) => re.test(l)).map(normalizeRedLine))].sort();
|
|
112
119
|
return lines.length ? lines : null;
|
|
113
120
|
};
|
package/src/run.js
CHANGED
|
@@ -125,6 +125,13 @@ async function primitiveSmoke(workdir) {
|
|
|
125
125
|
* RUN's ("unlimited replanning launders thrash as adaptation"). It also rides onto this
|
|
126
126
|
* run's `job-start` as `priorReplans`, the declared-fold precedent `priorSpentUsd`
|
|
127
127
|
* already sets, so a resume OF a resume folds once. Absent is the cold path.
|
|
128
|
+
* @param {string|null} [opts.resumeBranch] RESUME — the WORK BRANCH the killed attempt
|
|
129
|
+
* created and recorded on its own spine (`readResume`'s `restart.branch`). The branch
|
|
130
|
+
* name is DERIVED from the signed spec and is therefore identical on every leg, so
|
|
131
|
+
* without this the restart would collide with its own predecessor's branch and the
|
|
132
|
+
* collision walk would mint a `-2` beside the work the resume exists to continue. The
|
|
133
|
+
* fold's precedent exactly: the chain's state is DECLARED, never re-derived. Forwarded
|
|
134
|
+
* verbatim to the plan flow; absent is the cold path.
|
|
128
135
|
* @param {boolean} [opts.layerRoot=false] Layer R (within-run ratchet) — shell
|
|
129
136
|
* territory, threaded to the plan flow. Defaults OFF
|
|
130
137
|
* (decided 2026-07-21): fixation is extinct on every current job (F41), so
|
|
@@ -134,10 +141,10 @@ async function primitiveSmoke(workdir) {
|
|
|
134
141
|
* binding.)
|
|
135
142
|
* @returns {Promise<string>} outcome: 'green' | 'already-green' | 'escalated' |
|
|
136
143
|
* 'unapproved-spec' | 'job-red' | 'smoke-red' | 'plan-red' | 'check-red' |
|
|
137
|
-
* 'close-red' | 'close-unsupported' | 'recipe-stale' | 'pricing-red' | 'provider-red' |
|
|
144
|
+
* 'close-red' | 'close-unsupported' | 'recipe-stale' | 'branch-red' | 'pricing-red' | 'provider-red' |
|
|
138
145
|
* 'interpreter-red' | 'cap-halt' | 'wall-halt' | 'step-stalled' | `step-red:<id>`
|
|
139
146
|
*/
|
|
140
|
-
export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null }) {
|
|
147
|
+
export async function runJob(rawSpec, { approvals, workdir, provider, nativeProvider, providerFor, emit, capRuns = 3, strikeLimit, shellCapUsd = 2, closeTimeoutMs, layerRoot = false, bridge = null, priorSpentUsd = 0, priorSpendComplete = true, priorWallMs = 0, resumeSeed = null, resumeGrades = [], resumeReplans = null, resumeBranch = null }) {
|
|
141
148
|
// 0. the ledger's counters, declared FIRST so that every job-end — including
|
|
142
149
|
// the pre-token reds below — can state a real figure. An omitted `spentUsd` is
|
|
143
150
|
// not a zero: a consumer reads `undefined` and either crashes or launders it
|
|
@@ -275,7 +282,7 @@ export async function runJob(rawSpec, { approvals, workdir, provider, nativeProv
|
|
|
275
282
|
// accounts it natively (F12) and the job-end money contract is unchanged.
|
|
276
283
|
{
|
|
277
284
|
const outcome = await runPlan(job, {
|
|
278
|
-
workdir, provider, nativeProvider, providerFor, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, bridge, priorWallMs, resumeSeed, resumeGrades, resumeReplans,
|
|
285
|
+
workdir, provider, nativeProvider, providerFor, emit: meter, capRuns, ...(strikeLimit !== undefined ? { strikeLimit } : {}), closeTimeoutMs, layerRoot, bridge, priorWallMs, resumeSeed, resumeGrades, resumeReplans, resumeBranch,
|
|
279
286
|
remainingUsd: () => Math.min(shellCapUsd, job.budgetUsd - spentUsd),
|
|
280
287
|
isUnpriced: () => unpriced, // F6: let the plan flow bail in-flight, not just after it returns
|
|
281
288
|
spendComplete, // …and let its money-halt readout say whether the remaining it quotes is exact
|
package/src/text.js
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
// are two instruments (F2/F21, the adaptlearn instrument caveat). It replaced
|
|
5
5
|
// N0's stripFences when the fence-robust upgrade landed at N2. priceOf is the
|
|
6
6
|
// ONE spelling of the honest-null cost read for the same reason (F6).
|
|
7
|
+
// `scrubRaw` is the third: the ONE way a model's raw output becomes a persisted
|
|
8
|
+
// record, for the same reason again — a second writer that forgets the scrub is
|
|
9
|
+
// a leak on the very trail the scrub exists to protect.
|
|
10
|
+
|
|
11
|
+
import { redactSecrets } from './validate.js';
|
|
7
12
|
|
|
8
13
|
// A fence counts as the artifact's WRAPPER only when it opens within this many
|
|
9
14
|
// lines of the response — the chatty-preamble shape ("Here's the fix:\n```…").
|
|
@@ -64,3 +69,118 @@ export function priceOf(r) {
|
|
|
64
69
|
unpricedRounds: r?.metrics?.unpricedRounds ?? 0,
|
|
65
70
|
};
|
|
66
71
|
}
|
|
72
|
+
|
|
73
|
+
// ── THE RAW MODEL OUTPUT, AS A PERSISTED RECORD ─────────────────────────────
|
|
74
|
+
//
|
|
75
|
+
// Run mslhn707 died on a survey that broke JSON at position 1339 and left NO
|
|
76
|
+
// copy of what the model actually said: the red named the position, the process
|
|
77
|
+
// exited, and the only artifact that could have decided whether the malformation
|
|
78
|
+
// is recurring (and therefore whether the scout must move to a structure-forced
|
|
79
|
+
// tool call) went with it. A raw that is not written down is a diagnosis nobody
|
|
80
|
+
// can make twice.
|
|
81
|
+
//
|
|
82
|
+
// Everything a persisted raw needs is HERE rather than at each writer, and the
|
|
83
|
+
// reason is the `scrubRed` reason one layer up: the scrub, the bound and the
|
|
84
|
+
// announcement are properties of PERSISTING A RAW, not of any one caller
|
|
85
|
+
// remembering them. A future writer that reaches for a raw reaches for this.
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The ceiling on ONE persisted raw. Sized to the survey's own prompt-ingredient
|
|
89
|
+
* cap (`AUTHOR_SCOUT_BLOB_MAX`, 8000) rather than picked: the largest raw this
|
|
90
|
+
* repository produces is a survey, the archive's real surveys ran 5991-8056
|
|
91
|
+
* bytes, and a record that cannot hold one whole specimen cannot answer the
|
|
92
|
+
* question it exists for. The two constants are deliberately independent
|
|
93
|
+
* numbers — one bounds a PROMPT, this one bounds a RECORD.
|
|
94
|
+
*/
|
|
95
|
+
export const RAW_PERSIST_MAX = 8000;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The announced-trim marker for a persisted raw. A NEW spelling rather than a
|
|
99
|
+
* reuse of `GAP_TRIM_MARKER` ('gap trimmed:'), and that is a decision, not
|
|
100
|
+
* decoration: `src/root.js` READS `GAP_TRIM_MARKER` to mean *this gap was cut,
|
|
101
|
+
* so Layer R must not compare it*, so a second producer of those bytes in a
|
|
102
|
+
* different population is exactly the F90 hazard — a reader must know every
|
|
103
|
+
* producer of its marker. Nothing reads this one yet; it exists so the trim
|
|
104
|
+
* announces itself (F28) and so a future reader has an unambiguous key.
|
|
105
|
+
*/
|
|
106
|
+
export const RAW_TRIM_MARKER = 'raw trimmed:';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* ONE raw model emission, ready to persist.
|
|
110
|
+
*
|
|
111
|
+
* Three things happen here and NONE of them is optional at a call site:
|
|
112
|
+
* - the text goes through the ONE redactor (`redactSecrets`, over the ONE
|
|
113
|
+
* `SECRET_PATTERNS` inventory). A raw is the single most likely record in
|
|
114
|
+
* this system to carry a live credential: it is whatever a model read out
|
|
115
|
+
* of a repository, verbatim, and it lands in a file that outlives the run;
|
|
116
|
+
* - it is BOUNDED, and the bound ANNOUNCES itself with the full size (F28 —
|
|
117
|
+
* a bound eats detail, never evidence). The cut is decoded rather than
|
|
118
|
+
* sliced so a multi-byte character is never split in half;
|
|
119
|
+
* - it carries its own address: which call produced it (`label`, the same
|
|
120
|
+
* spelling the cost book meters under) and which attempt (`attempt`), plus
|
|
121
|
+
* the typed failure that describes it when there is one.
|
|
122
|
+
*
|
|
123
|
+
* `cause`/`reason` are `null` when the emission was fine — an ABSENT failure is
|
|
124
|
+
* absent, never an empty string that reads like a diagnosis nobody wrote.
|
|
125
|
+
* @param {{label: string, attempt: number, text: unknown, cause?: string|null,
|
|
126
|
+
* reason?: string|null, cap?: number}} o
|
|
127
|
+
* @returns {{label: string, attempt: number, bytes: number, trimmed: boolean,
|
|
128
|
+
* text: string, cause: string|null, reason: string|null}}
|
|
129
|
+
*/
|
|
130
|
+
export function scrubRaw({ label, attempt, text, cause = null, reason = null, cap = RAW_PERSIST_MAX }) {
|
|
131
|
+
const scrubbed = redactSecrets(String(text ?? ''));
|
|
132
|
+
const buf = Buffer.from(scrubbed, 'utf8');
|
|
133
|
+
const trimmed = buf.length > cap;
|
|
134
|
+
// walk the cut BACK off any continuation byte (0b10xxxxxx): a byte-wise slice
|
|
135
|
+
// through a multi-byte character renders as a replacement char, and a record
|
|
136
|
+
// that garbles the evidence is a worse instrument than one that keeps a byte
|
|
137
|
+
// less of it
|
|
138
|
+
let end = cap;
|
|
139
|
+
while (trimmed && end > 0 && (buf[end] & 0xC0) === 0x80) end -= 1;
|
|
140
|
+
const kept = trimmed ? buf.subarray(0, end).toString('utf8') : scrubbed;
|
|
141
|
+
// the two diagnosis fields are rendered by `stampRaw` below, never here: the
|
|
142
|
+
// writer that stamps them LATER must go through the same one place, or the
|
|
143
|
+
// scrub becomes a property of when you knew the verdict
|
|
144
|
+
return stampRaw({
|
|
145
|
+
label,
|
|
146
|
+
attempt,
|
|
147
|
+
// the FULL size, always — the number a trim is measured against
|
|
148
|
+
bytes: buf.length,
|
|
149
|
+
trimmed,
|
|
150
|
+
// WITHHELD is measured off `end`, not off `cap`: the multi-byte walk-back above
|
|
151
|
+
// can cut up to three bytes short of the cap, and announcing `length - cap` would
|
|
152
|
+
// under-report the withheld bytes by exactly that much. An announced bound that
|
|
153
|
+
// states a number it did not withhold is the announcement failing at its one job.
|
|
154
|
+
text: trimmed
|
|
155
|
+
? `${kept}\n[${RAW_TRIM_MARKER} ${buf.length - end} of ${buf.length} bytes withheld — the cap is ${cap} per raw]`
|
|
156
|
+
: kept,
|
|
157
|
+
cause: null,
|
|
158
|
+
reason: null,
|
|
159
|
+
}, { cause, reason });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Stamp a persisted raw with the verdict that DESCRIBES it — through the same
|
|
164
|
+
* boundary the raw's own text rode.
|
|
165
|
+
*
|
|
166
|
+
* It exists because the verdict is not known when the raw is created: a writer
|
|
167
|
+
* records the emission the moment the call returns (so a metered call can never
|
|
168
|
+
* be missing its output), and only classifies it afterwards. The obvious
|
|
169
|
+
* shape — assigning `cause`/`reason` onto the stored object at that later
|
|
170
|
+
* point — writes an UNSCRUBBED string into a record that is already past the
|
|
171
|
+
* one redactor, and `reason` is the worst field to do it to: it quotes the
|
|
172
|
+
* transport's own error prose, which nobody in this repo wrote and nobody
|
|
173
|
+
* bounds. So the stamp is a function, not an assignment, and `scrubRaw` renders
|
|
174
|
+
* its own two fields through it: ONE place decides how a diagnosis becomes
|
|
175
|
+
* persistable, exactly as ONE place decides it for the text.
|
|
176
|
+
*
|
|
177
|
+
* `cause` is enumerated (`SURVEY_CAUSES`) and passes through untouched; a mask
|
|
178
|
+
* over a closed set would only hide a bug in the set.
|
|
179
|
+
* @template {{cause: string|null, reason: string|null}} T
|
|
180
|
+
* @param {T} raw @param {{cause?: string|null, reason?: string|null}} o @returns {T}
|
|
181
|
+
*/
|
|
182
|
+
export function stampRaw(raw, { cause = null, reason = null }) {
|
|
183
|
+
raw.cause = cause ?? null;
|
|
184
|
+
raw.reason = reason === null || reason === undefined ? null : redactSecrets(reason);
|
|
185
|
+
return raw;
|
|
186
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// THE WORK BRANCH — runner mechanics, arbiter side. PRD Addendum v1.57 §3,
|
|
2
|
+
// hamr's ruling verbatim:
|
|
3
|
+
//
|
|
4
|
+
// "The agent creates a NEW BRANCH before it touches any code — a HARD RULE, no
|
|
5
|
+
// exceptions. Not a default, not a preference: no job edits the branch it was
|
|
6
|
+
// handed, and none edits `main`. Blast radius is bounded by where the work
|
|
7
|
+
// lands, not only by what the fence allows. Named with a meaningful slug —
|
|
8
|
+
// tool and problem, human-readable, of the form `bareloop-typecheck-fix`. A
|
|
9
|
+
// human scanning `git branch` must be able to tell what the run was for
|
|
10
|
+
// without opening the spec, which a run-id slug does not give them."
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT IS AND IS NOT, stated here rather than left to be inferred (v1.57's own
|
|
13
|
+
// known-limitations discipline). bareloop never commits: the run leaves its work
|
|
14
|
+
// in the tree. So the guarantee this module actually delivers is precise —
|
|
15
|
+
//
|
|
16
|
+
// * the run's tree is standing on a branch NOBODY handed it, from the first
|
|
17
|
+
// moment a write-capable worker can exist to the end of the run, and
|
|
18
|
+
// * the handed ref (`main`, or whatever was checked out) is never moved,
|
|
19
|
+
// never written through, and never the thing a later commit attaches to.
|
|
20
|
+
//
|
|
21
|
+
// It is NOT containment: v1.41's local-trust model stands untouched, and a close
|
|
22
|
+
// or worker child can still reach anything the operator's OS user can reach. The
|
|
23
|
+
// branch bounds where the work LANDS. That is the whole claim.
|
|
24
|
+
//
|
|
25
|
+
// THE NAME IS OURS, NEVER THE MODEL'S. A branch name is arbiter bookkeeping: it
|
|
26
|
+
// says which history a run's blast radius belongs to. A model-authored one would
|
|
27
|
+
// be the agent authoring the arbiter's own books, so the name is DERIVED,
|
|
28
|
+
// mechanically and deterministically, from the SIGNED spec's own `job` field
|
|
29
|
+
// (see `workBranchName`).
|
|
30
|
+
//
|
|
31
|
+
// WHY THIS FILE OWNS ITS OWN git SEAM. `src/kinds.js` has one too, and its
|
|
32
|
+
// docstring says what it is: "git, read-only, in a repository" — the CLOSE's
|
|
33
|
+
// measuring instrument, whose faults are close faults. This one MUTATES the
|
|
34
|
+
// repository (it creates and checks out a ref) and its faults are runner
|
|
35
|
+
// instrument stops before a single token is bought. Two verbs, two failure
|
|
36
|
+
// vocabularies, two owners; the read-only one is not exported and could not be
|
|
37
|
+
// shared without widening it into a write seam.
|
|
38
|
+
|
|
39
|
+
import { execFile } from 'node:child_process';
|
|
40
|
+
import { promisify } from 'node:util';
|
|
41
|
+
|
|
42
|
+
const exec = promisify(execFile);
|
|
43
|
+
|
|
44
|
+
/** Every work branch carries it. It is what makes `main`, `master` and every
|
|
45
|
+
* branch a human could plausibly have handed the run INEXPRESSIBLE as a work
|
|
46
|
+
* branch name — a structural guarantee rather than a denylist somebody has to
|
|
47
|
+
* keep current. */
|
|
48
|
+
export const WORK_BRANCH_PREFIX = 'bareloop-';
|
|
49
|
+
|
|
50
|
+
/** The full legal shape of a work branch. Deliberately narrower than git's own
|
|
51
|
+
* `check-ref-format`: the kebab alphabet the job validator already holds every
|
|
52
|
+
* spec slug to (`src/job.js` SLUG_RE), plus the prefix. Everything git forbids —
|
|
53
|
+
* `..`, `@{`, a trailing `.lock`, a leading `-`, a `/` component, control
|
|
54
|
+
* characters — is unreachable inside it, so ref-safety is a property of the
|
|
55
|
+
* alphabet and never a sanitiser somebody has to remember to call. */
|
|
56
|
+
export const WORK_BRANCH_RE = /^bareloop-[a-z0-9][a-z0-9-]*$/;
|
|
57
|
+
|
|
58
|
+
/** The slug's ceiling, before any collision suffix. A ref component has a
|
|
59
|
+
* filesystem-length limit and a human scanning `git branch` has a much smaller
|
|
60
|
+
* one; 60 characters is longer than every job slug in `jobs/` and short enough
|
|
61
|
+
* to read in a column. */
|
|
62
|
+
export const MAX_SLUG_LEN = 60;
|
|
63
|
+
|
|
64
|
+
/** How far the collision walk goes before it STOPS and says so. A run that
|
|
65
|
+
* cannot find a free name in this many attempts is looking at a patient nobody
|
|
66
|
+
* has swept in a very long time, and silently walking to `-500` would be the
|
|
67
|
+
* runner papering over an operator problem. */
|
|
68
|
+
export const MAX_BRANCH_COLLISIONS = 50;
|
|
69
|
+
|
|
70
|
+
/** git, in a repository, WRITE-CLASS. A non-zero exit is a value, never a throw:
|
|
71
|
+
* every caller here decides for itself whether git refusing is a stop.
|
|
72
|
+
* @param {string} cwd @param {string[]} args
|
|
73
|
+
* @returns {Promise<{ok: boolean, out: string, err: string}>}
|
|
74
|
+
*/
|
|
75
|
+
async function git(cwd, args) {
|
|
76
|
+
try {
|
|
77
|
+
const { stdout, stderr } = await exec('git', args, {
|
|
78
|
+
cwd, encoding: 'utf8', timeout: 120_000, maxBuffer: 1024 * 1024,
|
|
79
|
+
});
|
|
80
|
+
return { ok: true, out: String(stdout ?? ''), err: String(stderr ?? '') };
|
|
81
|
+
} catch (e) {
|
|
82
|
+
const err = /** @type {any} */ (e);
|
|
83
|
+
// A spawn failure (no git on PATH, cwd does not exist) has no streams at all;
|
|
84
|
+
// its `message` is the only thing there is to report, and reporting nothing
|
|
85
|
+
// would turn "git is not installed" into a mysterious silent refusal.
|
|
86
|
+
const detail = String(err?.stderr ?? '').trim() || String(err?.message ?? err).trim();
|
|
87
|
+
return { ok: false, out: String(err?.stdout ?? ''), err: detail };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The spec's own words, reduced to the kebab alphabet. Lossy on purpose: the
|
|
93
|
+
* output alphabet IS the ref-safety guarantee, so anything outside it collapses
|
|
94
|
+
* to a separator rather than being escaped into something clever.
|
|
95
|
+
* @param {unknown} s
|
|
96
|
+
* @returns {string} `''` when nothing usable survives
|
|
97
|
+
*/
|
|
98
|
+
function slugify(s) {
|
|
99
|
+
return String(s ?? '')
|
|
100
|
+
.toLowerCase()
|
|
101
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
102
|
+
.replace(/^-+/, '')
|
|
103
|
+
.slice(0, MAX_SLUG_LEN)
|
|
104
|
+
.replace(/-+$/, '');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The branch name a job's run works on, DERIVED from the signed spec.
|
|
109
|
+
*
|
|
110
|
+
* **The source is `spec.job`, and the choice is load-bearing.** It is the one
|
|
111
|
+
* human-authored, REQUIRED, kebab-case name the spec carries; it is inside the
|
|
112
|
+
* spec hash, so the branch name cannot drift from what the operator signed; and
|
|
113
|
+
* it is stable across a resume and across a cold re-run of the same spec, which
|
|
114
|
+
* is precisely why the collision walk below has to exist. In practice it already
|
|
115
|
+
* reads as tool-and-problem (`bareagent-u-types`, `litectx-u-types`), which is
|
|
116
|
+
* the shape hamr's ruling asks for.
|
|
117
|
+
*
|
|
118
|
+
* **The goal prose was REJECTED as a source**, and not for tidiness: a goal is
|
|
119
|
+
* long, is edited freely between runs without changing what the job IS, and
|
|
120
|
+
* opens on boilerplate ("An existing pytest test suite for the SOAR
|
|
121
|
+
* orchestrator…"). Deriving from it would make the branch name unstable under
|
|
122
|
+
* edits that change nothing, and unreadable when it was stable. A run id was
|
|
123
|
+
* rejected by the ruling itself.
|
|
124
|
+
*
|
|
125
|
+
* Deterministic, pure, and total: any input yields a name matching
|
|
126
|
+
* `WORK_BRANCH_RE`, so a caller never has to check.
|
|
127
|
+
* @param {any} spec a job-v1 spec (validated or raw)
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
export function workBranchName(spec) {
|
|
131
|
+
const slug = slugify(spec?.job);
|
|
132
|
+
// `job` is REQUIRED and kebab-validated by `validateJob`, which runs before the
|
|
133
|
+
// runner ever reaches here — so the fallback is unreachable through `runJob`.
|
|
134
|
+
// It exists because this function is exported and total: a name is the wrong
|
|
135
|
+
// thing to throw about, and `bareloop-job` is honest ("a bareloop job") rather
|
|
136
|
+
// than invented.
|
|
137
|
+
return `${WORK_BRANCH_PREFIX}${slug || 'job'}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** does a local branch of this name exist? @param {string} cwd @param {string} name */
|
|
141
|
+
async function branchExists(cwd, name) {
|
|
142
|
+
const r = await git(cwd, ['show-ref', '--verify', '--quiet', `refs/heads/${name}`]);
|
|
143
|
+
return r.ok;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Put the run on its OWN branch, before it can touch any code.
|
|
148
|
+
*
|
|
149
|
+
* The three shapes, and each one's honest answer:
|
|
150
|
+
*
|
|
151
|
+
* - **Cold.** `name` is free → create it at HEAD and switch. The handed branch
|
|
152
|
+
* keeps its ref and its history; a new branch at HEAD moves no commit, so this
|
|
153
|
+
* costs the patient nothing and changes nothing it can measure (the declared
|
|
154
|
+
* close's seed is HEAD either way).
|
|
155
|
+
* - **Cold, and the name is taken.** Suffix `-2`, `-3`… until one is free.
|
|
156
|
+
* Deterministic given the repository, and it NEVER reuses another run's
|
|
157
|
+
* branch — a second run of the same spec is a second blast radius.
|
|
158
|
+
* - **Resume.** `resume` names the branch the killed leg recorded on its own
|
|
159
|
+
* spine. It is checked out, never re-created and never suffixed: the work the
|
|
160
|
+
* operator already paid for is sitting on it, and minting `-2` beside it would
|
|
161
|
+
* strand exactly the progress a resume exists to keep. A `resume` branch that
|
|
162
|
+
* is GONE is a STOP, never a silent fresh start — the run would be grading a
|
|
163
|
+
* tree whose history it does not own.
|
|
164
|
+
*
|
|
165
|
+
* **Every failure is a stop with a name.** Not a git repository, no git on PATH,
|
|
166
|
+
* a checkout git refuses, a crowded namespace, a resume branch a human deleted:
|
|
167
|
+
* each comes back as `stop` and `branch: null`. There is no fallback to "just
|
|
168
|
+
* work on whatever was handed to us" anywhere in this function, because that
|
|
169
|
+
* fallback IS the thing the hard rule forbids.
|
|
170
|
+
*
|
|
171
|
+
* A DETACHED head is not a failure. Creating a branch from it is exactly the
|
|
172
|
+
* bounding the rule asks for (and it attaches a head that was floating), so it
|
|
173
|
+
* succeeds with `from: null` — there was no branch to be handed, and the record
|
|
174
|
+
* says so rather than inventing one.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} workdir the patient (the fence's root)
|
|
177
|
+
* @param {object} o
|
|
178
|
+
* @param {string} o.name the derived name (`workBranchName`); refused unless it
|
|
179
|
+
* matches `WORK_BRANCH_RE`, which is what makes `main` unreachable even from a
|
|
180
|
+
* hand-passed argument
|
|
181
|
+
* @param {string|null} [o.resume] RESUME — the branch this run already owns
|
|
182
|
+
* @returns {Promise<{stop: string, branch: null}
|
|
183
|
+
* | {stop: null, branch: string, created: boolean, resumed: boolean, from: string|null, base: string|null,
|
|
184
|
+
* repo: string, collided: number}>}
|
|
185
|
+
*/
|
|
186
|
+
export async function prepareWorkBranch(workdir, { name, resume = null }) {
|
|
187
|
+
const wanted = String(name ?? '');
|
|
188
|
+
if (!WORK_BRANCH_RE.test(wanted)) {
|
|
189
|
+
return { stop: `the work branch name ${JSON.stringify(wanted)} is not a bareloop work branch (${WORK_BRANCH_RE.source}) — a run never works on a branch it was handed`, branch: null };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Is there a repository at all? Asked FIRST and by its own question, so "this
|
|
193
|
+
// patient is not a git checkout" reads as itself instead of arriving later
|
|
194
|
+
// disguised as a checkout failure.
|
|
195
|
+
//
|
|
196
|
+
// `--show-toplevel` rather than `--git-dir`, and the answer is REPORTED: git
|
|
197
|
+
// resolves upwards, so a workdir that is a SUBDIRECTORY of a repository gets a
|
|
198
|
+
// branch in that enclosing repository — a blast radius wider than the operator
|
|
199
|
+
// handed over. That is the pre-existing semantics of every other git read in
|
|
200
|
+
// the flow (`seedAtHead`, `changedSet`), so it is not changed here; what it must
|
|
201
|
+
// not be is invisible, and the record names the repository that got the branch.
|
|
202
|
+
const inside = await git(workdir, ['rev-parse', '--show-toplevel']);
|
|
203
|
+
if (!inside.ok) {
|
|
204
|
+
return { stop: `the work branch cannot be created in ${workdir}: ${inside.err || 'not a git repository'}`, branch: null };
|
|
205
|
+
}
|
|
206
|
+
const repo = inside.out.trim().split('\n')[0].trim() || workdir;
|
|
207
|
+
|
|
208
|
+
// WHERE the run was handed. A symbolic HEAD names the handed branch; a detached
|
|
209
|
+
// one names nothing, and `null` is the honest reading of that (never the string
|
|
210
|
+
// "HEAD", which a reader cannot tell from a branch called HEAD).
|
|
211
|
+
const sym = await git(workdir, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
212
|
+
const from = sym.ok ? sym.out.trim() || null : null;
|
|
213
|
+
const rev = await git(workdir, ['rev-parse', 'HEAD']);
|
|
214
|
+
const base = rev.ok ? (rev.out.trim().split('\n')[0].trim() || null) : null;
|
|
215
|
+
|
|
216
|
+
if (resume !== null && resume !== undefined) {
|
|
217
|
+
const own = String(resume);
|
|
218
|
+
if (!WORK_BRANCH_RE.test(own)) {
|
|
219
|
+
return { stop: `the recorded work branch ${JSON.stringify(own)} is not a bareloop work branch (${WORK_BRANCH_RE.source}) — this resume would continue on a branch the run does not own`, branch: null };
|
|
220
|
+
}
|
|
221
|
+
if (!await branchExists(workdir, own)) {
|
|
222
|
+
return { stop: `the work branch ${JSON.stringify(own)} this run was killed on no longer exists in ${workdir} — the resume stops rather than starting a new branch beside work it cannot see`, branch: null };
|
|
223
|
+
}
|
|
224
|
+
// Already standing on it (the ordinary case — a kill leaves the tree where it
|
|
225
|
+
// was) is a NO-OP, deliberately: `git checkout` onto the current branch is
|
|
226
|
+
// harmless today, and not running it at all is what keeps a dirty in-flight
|
|
227
|
+
// tree out of git's hands entirely.
|
|
228
|
+
if (from !== own) {
|
|
229
|
+
const co = await git(workdir, ['checkout', own]);
|
|
230
|
+
if (!co.ok) return { stop: `could not return to the work branch ${JSON.stringify(own)} in ${workdir}: ${co.err}`, branch: null };
|
|
231
|
+
}
|
|
232
|
+
return { stop: null, branch: own, created: false, resumed: true, from, base, repo, collided: 0 };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The collision walk. `-2` is the first suffix rather than `-1`, so the branch a
|
|
236
|
+
// human reads without a number is the first run and the numbers count runs the
|
|
237
|
+
// way a person does.
|
|
238
|
+
//
|
|
239
|
+
// CHECK-THEN-ACT, named rather than fixed: another process can create the
|
|
240
|
+
// candidate between the `branchExists` read and the `checkout -b` below. It is
|
|
241
|
+
// documented instead of closed because git already fails CLOSED on it — `git
|
|
242
|
+
// checkout -b` REFUSES an existing branch, so the race ends in the named stop
|
|
243
|
+
// right beneath this loop, never in two runs sharing one blast radius. Closing
|
|
244
|
+
// it properly means atomic ref creation, which is a wider change than the
|
|
245
|
+
// failure it would prevent.
|
|
246
|
+
let collided = 0;
|
|
247
|
+
let candidate = wanted;
|
|
248
|
+
while (await branchExists(workdir, candidate)) {
|
|
249
|
+
collided += 1;
|
|
250
|
+
if (collided >= MAX_BRANCH_COLLISIONS) {
|
|
251
|
+
return { stop: `every work-branch name from ${JSON.stringify(wanted)} through ${JSON.stringify(`${wanted}-${MAX_BRANCH_COLLISIONS}`)} is already taken in ${workdir} — sweep the patient's old work branches rather than letting the runner walk further`, branch: null };
|
|
252
|
+
}
|
|
253
|
+
candidate = `${wanted}-${collided + 1}`;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const co = await git(workdir, ['checkout', '-b', candidate]);
|
|
257
|
+
if (!co.ok) {
|
|
258
|
+
return { stop: `could not create the work branch ${JSON.stringify(candidate)} in ${workdir}: ${co.err}`, branch: null };
|
|
259
|
+
}
|
|
260
|
+
return { stop: null, branch: candidate, created: true, resumed: false, from, base, repo, collided };
|
|
261
|
+
}
|