bus, business: agents land approved merges into the business working repository (row 137, #1631)

Row 137 round 4, Darkwing's candidate (agents/darkwing/work/queue-137/r4/,
build.patch 0cabe175, manifest 548ee477, 31 paths, base 63c82f1c4).
Filbert approved round 4 (comment 28809) and Dewey approved it (28816)
after round 3 changes (28801, 28805). Darkwing's round 4 ran 9/9 mutant
kills, bus 317, seat 150, land-work 86.

Round 5 is a rebase only (agents/sage/work/queue-137-r5/, build.patch
d6cbaaa8, manifest f73598fe). Row 150 changed docs/TOOLS.md, broker.mjs
and cli/README.md under it. The round 4 patch applied unchanged onto
435d1c616, and with index and @@ lines removed the two patches are
byte-identical. Filbert checked the interdiff (comment 28818).

A landing moves refs/heads/main in <dataRoot>/working/<business>/
<project>.git, never the project root or canonical refactor. The seed now
fetches the root as refs/heads/root and fast-forwards main only when it
is an ancestor, so a landed main survives the next push. Only a
cross-role merge decision lands. Its squash names the real channel:
"via the CLI" for the human, and "as its arbiter, via the broker" for an
arbiter's decision.resolve. Every other class refuses not-authorized
before the human check.

This carries row 139's two items. REQ-LAND-1: a gated merge decision
never lands. REQ-DEC-2: the host land op accepts an arbiter-resolved
merge. land-work's read.mjs stays human-only, and land-work.sh still
accepts only a human CLI resolution.

Sage's landing gate ran on 435d1c616 plus the candidate: 25 steps,
all rc 0 (15 packages, 10 script suites), 20:51:00Z to 20:55:34Z,
network sage-r137_default removed. Cherry-picked onto 5d98fb194, which
added only a docs/SESSIONS.md line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-16 15:56:00 -05:00
co-authored by Claude Opus 5.5
parent 5d98fb1944
commit f1ee9a9895
31 changed files with 2160 additions and 255 deletions
+8
View File
@@ -146,6 +146,14 @@ tool names it. Keep `TMPDIR` short: the gate inherits it, the cli suite
puts unix sockets under it, and a 46-byte `TMPDIR` failed 8 of those tests.
Push is a separate act.
Since #1631 the gate, its steps and the root-file rule live in
`packages/seat/src/landing.mjs`, and the decision checks in
`packages/bus/src/landing.mjs`. The bus host's `land` op uses both to land
an agent's task on its working repository's `main`, never on `refactor`
(`packages/cli/README.md`, "Git socket"). A decision raised for one target
can't land the other. land-work still accepts only a decision the human
resolved through the CLI.
## Maintenance
| Command | Purpose | Notes |
+43
View File
@@ -106,6 +106,49 @@ as `action.refused` on the task's trail.
addressed to (`request-not-found` otherwise). Returns the request's
`{project,commit,ref}`, which the host bundles for the reviewer.
Row 137 (#1631) adds four ops for a landing on a working repository's
`main` through the same git socket. The decision checks are land-work's,
in `src/landing.mjs` (`readLanding`), which `scripts/land-work/read.mjs`
also calls. Each caller names its branch, `refactor` for land-work and
the task ref for an agent's landing, and the decision's target has to
match it. So a decision raised for one can't land the other. land-work
accepts only a decision the human resolved through the CLI (`human/cli`).
The broker passes `arbiter: true`, which also accepts a `cross-role`
decision its arbiter resolved (REQ-DEC-2). It refuses a decision of any
class but `cross-role` with `not-authorized` (REQ-LAND-1). That covers
`gated` and `within-role`: only a role granted `git.merge.protected`
cross-role lands, whoever resolved the decision.
- `{op:'checkLand',cap,args}`: `args` is `{task_ref,decision}`. The caller
must hold its claim and the decision: `git.merge.protected`, raised by
this role in this thread with the task ref as its target, of class
`cross-role` (`not-authorized` otherwise), resolved with its approval
option and not yet used (`decision-consumed` otherwise).
Then `readLanding`'s checks, and the verdict's commit must be the task's
latest push (`ref-moved`). A landing with no subject refuses
`no-subject`, since an agent has no `--subject` to pass. Returns a
ticket with what the host needs: the project, the work ref, the commit,
the verdict and the resolution, with its `via` (`cli` or `broker`). It
records nothing unless it refuses.
- `{op:'recordLand',ticket,land}`: `land` is `{commit,previous,reviewed,
base,patch_id}` once `main` moved. The checks run again inside the
transaction. It writes the `action.allowed` for `git.merge.protected`
on the task's trail, which spends the decision, and sends the role a
RESULT in the decision's thread. When it refuses, the host moves `main`
back.
- `{op:'landRefused',ticket,args}`: `args` is `{code,detail,notify}`. It
writes `action.refused` with the code on the task's trail and ends the
landing. With `notify` the role gets a RESULT too, since the git socket
had already answered `started`.
- `{op:'landHeld',record}`: `record` is `{business,role,thread}`. True when
the role, in that thread (null for a role run), holds a `cross-role`
merge decision a landing could use. The launcher binds the git socket's land op only
then; `checkLand` still checks everything.
A ticket lives in the broker's memory from `checkLand` until `recordLand`
or `landRefused` ends it, so either may run after the run ended. An
unknown ticket refuses `land-unknown`.
`{op:'close'}`, SIGTERM and SIGINT close the socket and DB and remove the owned
writer lock. Losing the host IPC channel closes with exit 2. Startup or host
protocol failures return a static error code and exit 2, never a raw exception.
+174 -18
View File
@@ -1,5 +1,6 @@
import { randomUUID, randomBytes } from 'node:crypto';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -158,6 +159,8 @@ export class Broker {
#sessions = new Map();
#runs = new Map();
#ended = new Set();
// Row 137 (#1631): landings checkLand let start, by ticket, until recordLand or landRefused ends them.
#landings = new Map();
#secretCheck;
#credentialStatus;
#requirements;
@@ -648,8 +651,13 @@ export class Broker {
const tasks = rest.slice(0, limit);
return { tasks, total: matched.length, next: rest.length > limit ? tasks.at(-1).task_ref : null };
}
#checkAuthority(s, action, { decision = null, target = null } = {}) {
#checkAuthority(s, action, context = {}) {
this.#agent(s);
return this.#authorityFor(s, action, context);
}
// #checkAuthority less the holder check. A landing's record and refusal use it alone: the run
// that asked held its claim at checkLand, and may have ended while the gate ran (row 137).
#authorityFor(s, action, { decision = null, target = null } = {}) {
if (
action === 'role.launch' &&
this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked'
@@ -660,12 +668,7 @@ export class Broker {
if (!decision) fail('decision-required');
const d = this.#decision(s, decision),
r = this.#closed(d.id);
const evidence = this.#store.get(
"SELECT body FROM events WHERE business=? AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation')='decision.raise' AND kind='action.allowed' ORDER BY seq LIMIT 1",
s.business,
d.id,
);
const context = evidence ? JSON.parse(evidence.body) : null;
const context = this.#raised(s.business, d.id);
// Row 136 (#1630): bound to the role instance and its thread, not the run, so an approval
// outlives a restart. Single use is #consumeAuthority's.
if (
@@ -683,20 +686,29 @@ export class Broker {
fail('decision-mismatch');
return { class: cls, decision: d.id };
}
// The raise's context: its target and approval option, from the decision.raise action.allowed.
#raised(business, decision) {
const evidence = this.#store.get(
"SELECT body FROM events WHERE business=? AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation')='decision.raise' AND kind='action.allowed' ORDER BY seq LIMIT 1",
business,
decision,
);
return evidence ? JSON.parse(evidence.body) : null;
}
// Whether an executor already used the decision. decision.raise records context, not permission
// consumed by an executor.
#spent(business, decision) {
return !!this.#store.get(
"SELECT 1 FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IS NOT 'decision.raise' LIMIT 1",
business,
decision,
);
}
// Caller owns the transaction: authority, consumption and effect commit together.
// detail adds to the action.allowed body: a push's commit (row 93).
#consumeAuthority(s, action, context = {}, detail = {}) {
const result = this.#checkAuthority(s, action, context);
// decision.raise records context, not permission consumed by an executor.
if (
result.decision &&
this.#store.get(
"SELECT 1 FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IS NOT 'decision.raise' LIMIT 1",
s.business,
result.decision,
)
)
fail('decision-consumed');
if (result.decision && this.#spent(s.business, result.decision)) fail('decision-consumed');
this.#event(
s,
'action.allowed',
@@ -774,6 +786,150 @@ export class Broker {
return { task_ref, commit };
});
}
// Row 137 (#1631): the bus host's git socket lands a task on its working repository's main.
// checkLand runs before the host touches the repository. The run must hold its claim and the
// decision: git.merge.protected, raised by this role in this thread with the task ref as its
// target, resolved with its approval option and not yet used. Then land-work's checks
// (landing.mjs, into 'main'), and the verdict's commit must be the task's latest push, whose
// project the landing uses. A refusal is a code; nothing is recorded unless it refuses.
// The ticket names the landing to recordLand and landRefused, which may run after the run ended.
checkLand(cap, args) {
const s = this.#session(cap);
return this.#store.transaction(() => {
keys(args, ['task_ref', 'decision'], ['task_ref', 'decision']);
if (!taskRef(args.task_ref) || !id(args.decision)) fail('invalid-request');
const { task_ref, decision } = args;
const { class: cls } = this.#checkAuthority(s, MERGE, { decision, target: task_ref });
const facts = this.#landFacts(s, task_ref, decision, cls);
const ticket = randomUUID();
this.#landings.set(ticket, { s, task_ref, decision });
return { ticket, ...facts };
});
}
// What a landing needs, checked again at recordLand. PRD 1.5 REQ-LAND-1: only a role granted
// git.merge.protected cross-role lands, so a decision of any other class refuses, whoever
// resolved it: gated, or within-role, which its own raiser resolved. A cross-role one may be
// resolved by the human through the CLI or by its arbiter (REQ-DEC-2, landing.mjs `arbiter`).
// The decision's own project, when it names one, must be the push's.
#landFacts(s, task_ref, decision, cls) {
if (cls !== 'cross-role') fail('not-authorized');
if (this.#spent(s.business, decision)) fail('decision-consumed');
let l;
try {
l = readLanding(this.#store, this.#business(s.business), decision, { into: 'main', arbiter: true });
} catch (e) {
if (e instanceof LandingRefusal) fail(e.reason);
throw e;
}
if (l.task_ref !== task_ref) fail('work-ref');
const push = this.#latestPush(s.business, task_ref);
if (!push || push.commit !== l.commit) fail('ref-moved');
if (l.project && l.project !== push.project) fail('work-ref');
// land-work refuses a landing with no subject; an agent has no --subject to pass.
if (!l.subject) fail('no-subject');
return {
task_ref,
work_ref: l.work_ref,
decision,
project: push.project,
ref: push.ref,
commit: l.commit,
verdict: l.verdict,
subject: l.subject,
choice: l.choice,
by: l.by,
via: l.via,
resolved_at: l.resolved_at,
};
}
#landing(ticket) {
return (typeof ticket === 'string' && this.#landings.get(ticket)) || fail('land-unknown');
}
// Records a landing the git socket made (working.mjs finishLand) as the action.allowed for
// git.merge.protected that spends the decision, on the task's trail, and tells the role. The
// checks run again inside the transaction; a refusal here moves main back. Trusted host only.
recordLand(ticket, land) {
const t = this.#landing(ticket);
const { s, task_ref, decision } = t;
const done = this.#store.transaction(() => {
const all = ['commit', 'previous', 'reviewed', 'base', 'patch_id'];
keys(land, all, all);
if (!all.every((k) => oid(land[k]))) fail('invalid-request');
const result = this.#authorityFor(s, MERGE, { decision, target: task_ref });
const facts = this.#landFacts(s, task_ref, decision, result.class);
if (facts.commit !== land.reviewed) fail('ref-moved');
const { commit, previous, reviewed, base, patch_id } = land;
this.#event(
s,
'action.allowed',
{ project: facts.project, ref: 'refs/heads/main', commit, previous, reviewed, base, patch_id, verdict: facts.verdict, action: MERGE, ...result, target: task_ref },
task_ref,
);
this.#landMessage(s, decision, `Landed ${task_ref} on ${facts.project} main under decision ${decision}: main moved from ${previous} to ${commit}, the squash of reviewed ${reviewed}.`);
return { task_ref, commit };
});
this.#landings.delete(ticket);
return done;
}
// Ends a landing that didn't land: action.refused with the code on the task's trail, as a
// refusal of the run's own request is filed. With notify, the role is told too: the git socket
// had already answered "started" and the run has no other way to learn it. Trusted host only.
landRefused(ticket, { code, detail = null, notify = false } = {}) {
const t = this.#landing(ticket);
if (!/^[a-z-]{1,64}$/.test(code ?? '') || (detail !== null && !text(detail, 1000)) || typeof notify !== 'boolean')
fail('invalid-request');
this.#secretCheck({ detail });
this.#store.transaction(() => {
this.#event(t.s, 'action.refused', { code }, t.task_ref);
if (notify)
this.#landMessage(t.s, t.decision, `Landing of ${t.task_ref} under decision ${t.decision} refused: ${code}.${detail === null ? '' : `\n${detail}`}`);
});
this.#landings.delete(ticket);
return code;
}
// The outcome as a RESULT from the bus, as #notice sends: the runner takes a turn on it and sends
// no automatic reply. It goes to the role in the thread the decision was raised in.
#landMessage(s, decision, body) {
const mid = randomUUID();
this.#store.run(
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,in_reply_to,decision,corrects,body) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
mid,
this.#now(),
s.business,
NOTICE_ROLE,
NOTICE_RUN,
s.role,
'RESULT',
null,
decision,
null,
body,
);
this.#route(s.business, mid, s.role, s.thread);
}
// Trusted launcher API: whether the role, in the thread (null for a role run), holds a merge
// decision a landing could use: cross-role, raised there with a task ref as its target, resolved
// with its approval option, not yet used. The launcher binds the git socket's land op only then;
// checkLand still checks everything.
landHeld(record) {
keys(record, ['business', 'role', 'thread'], ['business', 'role', 'thread']);
this.#business(identifier(record.business));
identifier(record.role);
if (record.thread !== null) identifier(record.thread);
for (const d of this.#store.all(
'SELECT * FROM decisions WHERE business=? AND action=? AND raised_by_role=? ORDER BY seq',
record.business,
MERGE,
record.role,
)) {
if (d.class !== 'cross-role' || this.#raisedThread(d) !== record.thread) continue;
const context = this.#raised(d.business, d.id);
const r = this.#closed(d.id);
if (context && taskRef(context.target) && r?.op === 'resolved' && r.choice === context.approvalChoice && !this.#spent(d.business, d.id))
return true;
}
return false;
}
// Row 93: what the git socket's fetch for a reviewer may send, from the review.requested event the
// reviewer cites. The caller must hold review.verdict and be the request's reviewer. Trusted host only.
reviewFetch(cap, { task_ref, request } = {}) {
@@ -804,7 +960,7 @@ export class Broker {
return this.#store.transaction(() => {
this.#agent(s);
if (['human.input', 'launch.revoked', 'launch.restored', ...REVIEW_KINDS].includes(kind)) fail('reserved-event');
if (kind === 'action.allowed' && body?.action === 'git.push.working') fail('reserved-event');
if (kind === 'action.allowed' && ['git.push.working', MERGE].includes(body?.action)) fail('reserved-event');
return this.#event(s, kind, body, subject);
});
}
+159
View File
@@ -0,0 +1,159 @@
// The checks a landing makes on its merge decision and the task's review
// (row 137, #1631). Two callers: scripts/land-work/read.mjs, which lands on
// canonical refactor from a read-only copy of the bus, and the broker's
// checkLand and recordLand, which land on a working repository's main.
// Moved here from read.mjs (row 116, #1610); every query is a SELECT.
//
// readLanding(q, business, id, { into, arbiter }) runs over q.get(sql, ...params)
// and q.all(sql, ...params). It throws a LandingRefusal with land-work's reason
// codes, or returns what the landing needs.
//
// `into` is the branch the caller lands on, and the raise's target must
// match it: 'refactor' for land-work, the task ref for a working repository's
// main. So a decision raised for one can't be used for the other, and a
// decision raised by an agent for its task never names refactor.
//
// Without `arbiter`, the human must have resolved the decision through the
// CLI: land-work leaves it out, because canonical refactor stays the lead's
// landing (PRD 1.5). The broker passes `arbiter: true` (REQ-DEC-2), which
// also accepts a cross-role decision routed to an arbiter and resolved by it.
// The broker lets only a run of the route_to role resolve one, and a hard wait
// is never cross-role, so no hard wait passes here (row 139 routes them to the
// human).
export const MERGE = 'git.merge.protected';
const WORK_REF = /^work\/vikunja-([1-9][0-9]*)-([1-9][0-9]*)$/;
const ANY_WORK_REF = /\bwork\/[A-Za-z0-9._/-]*[A-Za-z0-9_]/g;
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
export class LandingRefusal extends Error {
constructor(reason, detail) {
super(detail);
this.reason = reason;
}
}
const refuse = (reason, detail) => {
throw new LandingRefusal(reason, detail);
};
const json = (text) => {
try {
return JSON.parse(text);
} catch {
return null;
}
};
// The queries. Each takes the business so one bus can't answer for another.
const queries = {
decision: (q, b, id) => q.get('SELECT * FROM decisions WHERE business = ? AND id = ?', b, id),
// The broker files a raise under its task ref when it has one, else under the decision id
// (decision.raise), so the raise is found by the decision its body names.
raise: (q, b, id) =>
q.all(
`SELECT * FROM events WHERE business = ? AND kind = 'action.allowed'
AND json_extract(body, '$.operation') = 'decision.raise' AND json_extract(body, '$.decision') = ?
ORDER BY seq`,
b,
id,
),
closes: (q, id) =>
q.all(`SELECT * FROM decision_events WHERE decision = ? AND op IN ('resolved','withdrawn','expired') ORDER BY seq`, id),
answer: (q, b, id) =>
q.all(
`SELECT * FROM events WHERE business = ? AND kind = 'human.input' AND subject = ?
AND json_extract(body, '$.kind') = 'answer' AND json_extract(body, '$.decision') = ?
AND json_extract(body, '$.op') = 'resolved'
ORDER BY seq`,
b,
id,
id,
),
later: (q, b, d) =>
q.all(
`SELECT * FROM decisions WHERE business = ? AND id <> ?
AND (supersedes = ? OR (seq > ? AND action = ?)) ORDER BY seq`,
b,
d.id,
d.id,
d.seq,
MERGE,
),
verdict: (q, b, task) =>
q.get(`SELECT * FROM events WHERE business = ? AND kind = 'review.verdict' AND subject = ? ORDER BY seq DESC LIMIT 1`, b, task),
};
// The task ref a work ref names, or null.
export function workTask(workRef) {
const m = WORK_REF.exec(workRef ?? '');
return m ? `vikunja:${m[1]}/${m[2]}` : null;
}
export function readLanding(q, business, id, { into, arbiter = false }) {
const b = business.id;
const d = queries.decision(q, b, id);
const raises = queries.raise(q, b, id);
if (!d || raises.length !== 1) refuse('decision-not-found', `no decision ${id} with one raise in business ${b}`);
const raise = json(raises[0].body) ?? {};
if (d.action !== MERGE || raise.action !== MERGE) refuse('not-a-merge', `action ${d.action}`);
const closes = queries.closes(q, id);
if (closes.length !== 1 || closes[0].op !== 'resolved')
refuse('not-resolved', closes.length ? closes.map((r) => r.op).join(',') : 'open');
const res = closes[0];
const answers = queries.answer(q, b, id);
const answer = answers.length === 1 ? answers[0] : null;
const human =
res.by === business.human &&
res.via === 'cli' &&
answer &&
answer.actor_role === null &&
answer.actor_run === null &&
json(answer.body)?.choice === res.choice;
// A cross-role decision routed to an agent closes only by decision.resolve, which takes it from
// the routed role alone (s.role === route_to), so res.by here is the arbiter's run.
const arbitrated = arbiter && d.class === 'cross-role' && d.route_to !== 'human' && res.via === 'broker' && !answers.length;
if (!human && !arbitrated)
refuse('not-human', `resolved by ${res.by} via ${res.via}, ${answers.length} human answer(s)`);
const options = json(d.options) ?? [];
const merge = options.find((o) => o?.key === raise.approvalChoice);
if (!raise.approvalChoice || !merge) refuse('not-merge-option', 'the raise names no approval option');
if (res.choice !== raise.approvalChoice)
refuse('not-merge-option', `chose ${res.choice}, the merge option is ${raise.approvalChoice}`);
// The work ref comes from the question: decisions don't carry it.
const named = [...new Set(d.question.match(ANY_WORK_REF) ?? [])];
const task = named.length === 1 ? workTask(named[0]) : null;
if (!task) refuse('work-ref', `the question names ${named.join(', ') || 'no work ref'}`);
const workRef = named[0];
for (const ref of [d.task_ref, raise.task_ref])
if (ref != null && ref !== task) refuse('work-ref', `the decision cites ${ref}, the question ${workRef}`);
// The target is the raise's: refactor for land-work, the task itself for a working repository.
const want = into === 'refactor' ? 'refactor' : into === 'main' ? task : null;
if (want === null || raise.target !== want)
refuse('target-branch', `the raise targets ${raise.target}, landing on ${into} needs ${want}`);
const later = queries.later(q, b, d).filter(
(r) => r.supersedes === id || r.task_ref === task || (r.question.match(ANY_WORK_REF) ?? []).includes(workRef),
);
if (later.length) refuse('superseded', `later decision ${later.map((r) => r.id).join(', ')} on ${workRef}`);
const v = queries.verdict(q, b, task);
if (!v) refuse('no-verdict', `no review.verdict for ${task}`);
const vb = json(v.body) ?? {};
if (vb.verdict !== 'approve' || vb.task_ref !== task || !OID.test(vb.commit ?? ''))
refuse('not-approved', `latest verdict ${v.id} is ${vb.verdict} on ${vb.commit}`);
return {
task_ref: task,
work_ref: workRef,
commit: vb.commit,
verdict: v.id,
project: d.project ?? '',
choice: res.choice,
by: res.by,
via: res.via,
resolved_at: res.at,
subject: String(merge.text ?? '').split('\n')[0].trim(),
};
}
+9
View File
@@ -11,6 +11,10 @@ const LAUNCH_OPS = new Set([
'checkPush',
'recordPush',
'reviewFetch',
'checkLand',
'recordLand',
'landRefused',
'landHeld',
'threadsState',
'threadIdle',
]);
@@ -23,6 +27,11 @@ function launchOp(m) {
if (m.op === 'checkPush') return runtime.checkPush(m.cap, m.task_ref);
if (m.op === 'recordPush') return runtime.recordPush(m.cap, m.push);
if (m.op === 'reviewFetch') return runtime.reviewFetch(m.cap, m.args);
// Row 137 (#1631): the git socket's land op, and the launcher's question whether to bind it.
if (m.op === 'checkLand') return runtime.checkLand(m.cap, m.args);
if (m.op === 'recordLand') return runtime.recordLand(m.ticket, m.land);
if (m.op === 'landRefused') return runtime.landRefused(m.ticket, m.args);
if (m.op === 'landHeld') return runtime.landHeld(m.record);
// Row 121 (#1615): the launcher's view of the business's threads, and its idle release.
if (m.op === 'threadsState') return runtime.threadsState(m.business);
if (m.op === 'threadIdle') return runtime.threadIdle(m.record);
+7
View File
@@ -141,6 +141,9 @@ export async function startBroker({
// and recorded once the ref moved; a reviewer's fetch is checked against its review.requested.
const checkPush = (cap, task_ref) => refusing(cap, () => broker.checkPush(cap, task_ref), task_ref);
const reviewFetch = (cap, args) => refusing(cap, () => broker.reviewFetch(cap, args), args?.task_ref);
// Row 137 (#1631): the git socket's land op. checkLand's refusal is the run's, like checkPush's.
// A landing then ends in recordLand or landRefused under its ticket, which outlive the run's cap.
const checkLand = (cap, args) => refusing(cap, () => broker.checkLand(cap, args), args?.task_ref);
// Metadata only (service, date, state); the token stays in Credentials.
function credentialStatus(business, role) {
return credentials
@@ -161,6 +164,10 @@ export async function startBroker({
checkPush,
recordPush: (cap, push) => refusing(cap, () => broker.recordPush(cap, push), push?.task_ref),
reviewFetch,
checkLand,
recordLand: (ticket, land) => broker.recordLand(ticket, land),
landRefused: (ticket, args) => broker.landRefused(ticket, args),
landHeld: (record) => broker.landHeld(record),
refuse: (cap, code, subject) => broker.refuse(cap, code, subject),
credentialStatus,
launches: bound,
+362
View File
@@ -0,0 +1,362 @@
// Row 137 (#1631): the broker's side of an agent's landing. The git socket asks
// checkLand before it touches the working repository, then ends the landing
// with recordLand, which spends the merge decision, or landRefused. landHeld is
// the launcher's question whether to offer land_work at all.
//
// The lander is merge-gate, granted git.merge.protected cross-role (PRD 1.5
// REQ-LAND-1), and its raise goes to the arbiter pm (REQ-DEC-2). The coder
// pushes; its own merge raise is gated, and gated never lands.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { readLanding } from '../src/landing.mjs';
import { fixture } from './spec-fixtures.mjs';
const TASK = 'vikunja:1/2';
const MERGE = 'git.merge.protected';
const A = 'a'.repeat(40);
const B = 'b'.repeat(40);
const C = 'c'.repeat(40);
const roles = {
pm: { authority: { withinRole: ['message.send'], crossRole: [] } },
coder: { authority: { withinRole: ['git.push.working', 'review.request', 'message.send'], crossRole: [] } },
reviewer: { authority: { withinRole: ['review.verdict', 'message.send'], crossRole: [] } },
'merge-gate': { authority: { withinRole: [], crossRole: [MERGE] } },
};
const demo = (arbiters = { technical: 'pm', delivery: 'pm' }, r = roles) => ({
id: 'demo',
human: 'jason',
arbiters,
projects: { stack: { root: '/nonexistent' } },
roles: r,
});
const options = [
{ key: 'yes', text: 'Land the parser fix\nIt is reviewed.' },
{ key: 'no', text: 'Not yet' },
];
const merge = { action: MERGE, target: TASK, question: 'Squash-merge work/vikunja-1-2 into main?', options, recommendation: 'yes', blocking: false };
function setup(t, { arbiters, roles: r } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-land-'));
const store = new Store(root);
const businesses = { demo: demo(arbiters, r) };
const b = new Broker({ store, businesses });
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }) };
for (const role of Object.keys(businesses.demo.roles)) {
caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
b.request(caps[role], { verb: 'role.claim', args: {} });
}
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const push = (commit, previous = null) => {
b.checkPush(caps.coder, TASK);
return b.recordPush(caps.coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-1-2', commit, previous });
};
const approve = (commit) => {
const r = call('coder', 'review.request', { task_ref: TASK, commit, to: 'reviewer' });
return call('reviewer', 'review.verdict', { task_ref: TASK, request: r.request, verdict: 'approve', body: 'good' });
};
const raise = (who = 'merge-gate', extra = {}) => call(who, 'decision.raise', { ...merge, ...extra });
// The arbiter resolves by default; a decision routed to the human needs who = 'human'.
const resolve = (d, choice = 'yes', who = 'pm') => call(who, 'decision.resolve', { id: d.id, choice });
// A pushed, approved task with a merge decision its arbiter approved.
const ready = () => {
push(A);
const v = approve(A);
const d = raise();
resolve(d);
return { d, v };
};
const land = { commit: B, previous: C, reviewed: A, base: C, patch_id: 'd'.repeat(40) };
const events = (kind) => store.all('SELECT * FROM events WHERE kind=? ORDER BY seq', kind).map((e) => ({ ...e, body: JSON.parse(e.body) }));
const merges = () => events('action.allowed').filter((e) => e.body.action === MERGE && e.body.operation !== 'decision.raise');
// The landing's RESULTs; a #notice is a RESULT too.
const results = () =>
store.all("SELECT * FROM messages WHERE class='RESULT' AND (body LIKE 'Landed %' OR body LIKE 'Landing of %') ORDER BY at, id").map((m) => ({ ...m }));
const held = (role = 'merge-gate', thread = null) => b.landHeld({ business: 'demo', role, thread });
const check = (args) => b.checkLand(caps['merge-gate'], args);
const routed = (d) => ({ ...store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) });
return { b, store, businesses, caps, call, push, approve, raise, resolve, ready, land, events, merges, results, held, check, routed };
}
test('REQ-DEC-2: a merge-gate raise its arbiter resolved lands through the host op: checkLand answers the facts and a ticket, records nothing, and recordLand spends the decision once', (t) => {
const w = setup(t);
const { d, v } = w.ready();
assert.deepEqual(w.routed(d), { class: 'cross-role', route_to: 'pm' });
assert.deepEqual({ ...w.store.get('SELECT "by", via FROM decision_events WHERE decision=? AND op=\'resolved\'', d.id) }, { by: 'pm-run', via: 'broker' });
// land-work's copy of the check, without `arbiter`, still wants the human.
assert.throws(() => readLanding(w.store, w.businesses.demo, d.id, { into: 'main' }), { reason: 'not-human' });
const r = w.check({ task_ref: TASK, decision: d.id });
const { ticket, resolved_at, ...facts } = r;
assert.match(ticket, /^[0-9a-f-]{36}$/);
assert.match(resolved_at, /^\d{4}-/);
assert.deepEqual(facts, {
task_ref: TASK,
work_ref: 'work/vikunja-1-2',
decision: d.id,
project: 'stack',
ref: 'work/vikunja-1-2',
commit: A,
verdict: v.verdict,
subject: 'Land the parser fix',
choice: 'yes',
by: 'pm-run',
via: 'broker',
});
assert.deepEqual(w.merges(), []);
assert.deepEqual(w.events('action.refused'), []);
assert.deepEqual(w.b.recordLand(ticket, w.land), { task_ref: TASK, commit: B });
const [e] = w.merges();
assert.equal(e.subject, TASK);
assert.equal(e.actor_role, 'merge-gate');
assert.deepEqual(e.body, {
project: 'stack',
ref: 'refs/heads/main',
commit: B,
previous: C,
reviewed: A,
base: C,
patch_id: 'd'.repeat(40),
verdict: v.verdict,
action: MERGE,
class: 'cross-role',
decision: d.id,
target: TASK,
});
const [m] = w.results();
assert.equal(m.to_role, 'merge-gate');
assert.equal(m.decision, d.id);
assert.match(m.body, new RegExp(`^Landed ${TASK} on stack main under decision ${d.id}: main moved from ${C} to ${B}`));
// Spent: a second landing, and the old ticket, refuse.
assert.throws(() => w.check({ task_ref: TASK, decision: d.id }), /decision-consumed/);
assert.throws(() => w.b.recordLand(ticket, w.land), /land-unknown/);
assert.equal(w.held(), false);
});
test('checkLand refuses on the decision and the review', (t) => {
const w = setup(t);
const { check } = w;
assert.throws(() => check({ task_ref: TASK }), /invalid-request/);
assert.throws(() => check({ task_ref: 'gitea:1', decision: 'x' }), /invalid-request/);
assert.throws(() => check({ task_ref: TASK, decision: 'x', extra: 1 }), /invalid-request/);
// No decision yet, then one that isn't resolved.
assert.throws(() => check({ task_ref: TASK, decision: 'no-such' }), /decision-not-found/);
w.push(A);
const open = w.raise();
assert.throws(() => check({ task_ref: TASK, decision: open.id }), /decision-not-approved/);
w.resolve(open, 'no');
assert.throws(() => check({ task_ref: TASK, decision: open.id }), /decision-not-approved/);
// Approved, but by another role's raise, or for another target. pm's raise is gated and
// uncited, so it goes to the human.
const pmRaise = w.raise('pm');
w.resolve(pmRaise, 'yes', 'human');
assert.throws(() => check({ task_ref: TASK, decision: pmRaise.id }), /decision-mismatch/);
const other = w.raise('merge-gate', { target: 'vikunja:1/3' });
w.resolve(other);
assert.throws(() => check({ task_ref: TASK, decision: other.id }), /decision-mismatch/);
// Approved for this task, but nothing reviewed it yet.
const d = w.raise();
w.resolve(d);
assert.throws(() => check({ task_ref: TASK, decision: d.id }), /no-verdict/);
// The reviewer approved A, then the coder pushed B.
w.approve(A);
w.push(B, A);
assert.throws(() => check({ task_ref: TASK, decision: d.id }), /ref-moved/);
assert.deepEqual(w.merges(), []);
});
// PRD 1.5 REQ-LAND-1. The coder has no cross-role grant, so its merge raise is gated: cited, it
// goes to the arbiter (row 139); uncited, to the human. Approved either way, it never lands.
test('REQ-LAND-1: a gated merge decision refuses not-authorized, cited and approved by the arbiter or uncited and approved by the human', async (t) => {
const f = await fixture(t, { roles, arbiters: { technical: 'pm', delivery: 'pm' } });
const pm = f.agent('pm');
f.broker.checkPush(f.coder, TASK);
f.broker.recordPush(f.coder, { task_ref: TASK, project: 'stack', branch: 'feat', ref: 'work/vikunja-1-2', commit: A, previous: null });
const r = f.call(f.coder, 'review.request', { task_ref: TASK, commit: A, to: 'reviewer' });
f.call(f.reviewer, 'review.verdict', { task_ref: TASK, request: r.request, verdict: 'approve', body: 'good' });
const d = await f.call(f.coder, 'decision.raise', { ...merge, project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
assert.deepEqual({ ...f.store.get('SELECT class, route_to FROM decisions WHERE id=?', d.id) }, { class: 'gated', route_to: 'pm' });
f.call(pm, 'decision.resolve', { id: d.id, choice: 'yes' });
assert.throws(() => f.broker.checkLand(f.coder, { task_ref: TASK, decision: d.id }), /not-authorized/);
// The shared check refuses it too: its arbiter path takes only a cross-role decision.
assert.throws(() => readLanding(f.store, f.business, d.id, { into: 'main', arbiter: true }), { reason: 'not-human' });
assert.equal(f.broker.landHeld({ business: 'demo', role: 'coder', thread: null }), false);
const w = setup(t);
w.push(A);
w.approve(A);
const human = w.raise('coder');
assert.deepEqual(w.routed(human), { class: 'gated', route_to: 'human' });
w.resolve(human, 'yes', 'human');
assert.throws(() => w.b.checkLand(w.caps.coder, { task_ref: TASK, decision: human.id }), /not-authorized/);
assert.deepEqual([...w.merges(), ...w.events('action.refused')], []);
});
// Round 4 (sage-0585): a role granted git.merge.protected within-role resolves its own merge
// raise. That isn't the cross-role grant REQ-LAND-1 asks for, so it refuses at the land too.
test('REQ-LAND-1: a within-role merge grant refuses not-authorized at the land', (t) => {
const w = setup(t, {
roles: { ...roles, 'merge-gate': { authority: { withinRole: [MERGE], crossRole: [] } } },
});
w.push(A);
w.approve(A);
const d = w.raise('merge-gate', { choice: 'yes' });
assert.deepEqual(w.routed(d), { class: 'within-role', route_to: 'merge-gate' });
assert.deepEqual({ ...w.store.get('SELECT op, choice, via FROM decision_events WHERE decision=?', d.id) }, { op: 'resolved', choice: 'yes', via: 'broker' });
assert.throws(() => w.check({ task_ref: TASK, decision: d.id }), /not-authorized/);
assert.equal(w.held(), false);
assert.deepEqual([...w.merges(), ...w.events('action.refused')], []);
});
test('a cross-role merge that routes to the human, because merge-gate is its own arbiter, lands on the human\'s CLI answer', (t) => {
const w = setup(t, { arbiters: { technical: 'merge-gate', delivery: 'merge-gate' } });
w.push(A);
w.approve(A);
const d = w.raise();
assert.deepEqual(w.routed(d), { class: 'cross-role', route_to: 'human' });
assert.throws(() => w.resolve(d), /human-required/);
w.resolve(d, 'yes', 'human');
const { ticket, by, via } = w.check({ task_ref: TASK, decision: d.id });
assert.deepEqual([by, via], ['jason', 'cli']);
w.b.recordLand(ticket, w.land);
assert.equal(w.merges()[0].body.class, 'cross-role');
});
test('a merge option with no first line refuses no-subject, as land-work does', (t) => {
const w = setup(t);
w.push(A);
w.approve(A);
const d = w.raise('merge-gate', {
options: [
{ key: 'yes', text: '\nLand it' },
{ key: 'no', text: 'Not yet' },
],
});
w.resolve(d);
assert.throws(() => w.check({ task_ref: TASK, decision: d.id }), /no-subject/);
});
// The codes readLanding gives once #checkAuthority has passed, on the 'main'
// side. scripts/test-land-work.sh runs every code on the 'refactor' side.
// checkLand never gives not-human: #landFacts refuses every class but
// cross-role with not-authorized first, and the broker resolves a cross-role
// decision only the two ways readLanding takes with `arbiter`, the human's CLI
// answer or its arbiter's decision.resolve. The REQ-LAND-1 test calls
// readLanding for not-human.
test("checkLand refuses with land-work's codes past the authority check, and a decision raised for refactor never lands", (t) => {
const w = setup(t);
const check = (d) => () => w.check({ task_ref: TASK, decision: d.id });
w.push(A);
w.approve(A);
const approved = (extra) => {
const d = w.raise('merge-gate', extra);
w.resolve(d);
return d;
};
assert.throws(check(approved({ question: 'Squash-merge work/vikunja-1-2 and work/vikunja-1-3 into main?' })), /work-ref/);
assert.throws(check(approved({ question: 'Squash-merge work/vikunja-1-2 into main?', task_ref: 'vikunja:1/3' })), /work-ref/);
// The raise targets this task, but the question names another.
assert.throws(check(approved({ question: 'Squash-merge work/vikunja-1-3 into main?' })), /target-branch/);
// land-work's kind of decision: the authority check wants the task as the target.
assert.throws(check(approved({ target: 'refactor' })), /decision-mismatch/);
// A later merge decision on the same work ref supersedes an approved one.
const first = approved();
w.raise();
assert.throws(check(first), /superseded/);
// A later verdict asks for changes on the same commit.
const v = setup(t);
v.push(A);
v.approve(A);
const d = v.raise();
v.resolve(d);
const r = v.call('coder', 'review.request', { task_ref: TASK, commit: A, to: 'reviewer' });
v.call('reviewer', 'review.verdict', { task_ref: TASK, request: r.request, verdict: 'changes', body: 'one more thing' });
assert.throws(() => v.check({ task_ref: TASK, decision: d.id }), /not-approved/);
assert.deepEqual([...w.merges(), ...v.merges()], []);
});
test('a ticket outlives the run that asked, and recordLand checks the task again', (t) => {
const w = setup(t);
const { d } = w.ready();
const { ticket } = w.check({ task_ref: TASK, decision: d.id });
// A bad record refuses and keeps the ticket.
assert.throws(() => w.b.recordLand(ticket, { ...w.land, commit: 'x' }), /invalid-request/);
const { patch_id, ...short } = w.land;
assert.throws(() => w.b.recordLand(ticket, short), /invalid-request/);
assert.throws(() => w.b.recordLand(ticket, { ...w.land, reviewed: C }), /ref-moved/);
// The run ends while the gate runs; the landing is still recorded as merge-gate's.
w.b.endLaunch({ business: 'demo', run: 'merge-gate-run', reason: 'exited' });
assert.deepEqual(w.b.recordLand(ticket, w.land), { task_ref: TASK, commit: B });
assert.equal(w.merges()[0].actor_role, 'merge-gate');
assert.throws(() => w.b.recordLand('no-such-ticket', w.land), /land-unknown/);
assert.throws(() => w.b.landRefused(ticket, { code: 'gate-failed' }), /land-unknown/);
});
test('landRefused files the code on the task, tells the role only with notify, and ends the ticket', (t) => {
const w = setup(t);
const { d } = w.ready();
const first = w.check({ task_ref: TASK, decision: d.id });
assert.throws(() => w.b.landRefused(first.ticket, { code: 'Not A Code' }), /invalid-request/);
assert.throws(() => w.b.landRefused(first.ticket, { code: 'x', detail: '' }), /invalid-request/);
assert.throws(() => w.b.landRefused(first.ticket, { code: 'x', notify: 'yes' }), /invalid-request/);
assert.equal(w.b.landRefused(first.ticket, { code: 'unknown-project' }), 'unknown-project');
assert.deepEqual(w.results(), []);
const second = w.check({ task_ref: TASK, decision: d.id });
w.b.landRefused(second.ticket, { code: 'gate-failed', detail: 'pkg-bus failed, see x.log', notify: true });
const [m] = w.results();
assert.equal(m.to_role, 'merge-gate');
assert.equal(m.body, `Landing of ${TASK} under decision ${d.id} refused: gate-failed.\npkg-bus failed, see x.log`);
assert.deepEqual(
w.events('action.refused').map((e) => [e.subject, e.actor_role, e.body.code]),
[
[TASK, 'merge-gate', 'unknown-project'],
[TASK, 'merge-gate', 'gate-failed'],
],
);
// A refusal spends nothing: the decision still lands.
assert.equal(w.held(), true);
const third = w.check({ task_ref: TASK, decision: d.id });
w.b.recordLand(third.ticket, w.land);
assert.throws(() => w.b.landRefused(second.ticket, { code: 'x' }), /land-unknown/);
});
test('landHeld is true only for an approved, unspent cross-role merge decision this role raised for a task in this thread', (t) => {
const w = setup(t);
assert.equal(w.held(), false);
w.push(A);
const d = w.raise();
assert.equal(w.held(), false, 'open');
w.resolve(d, 'no');
assert.equal(w.held(), false, 'declined');
const notTask = w.raise('merge-gate', { target: 'main' });
w.resolve(notTask);
assert.equal(w.held(), false, 'a target that is not a task ref');
const gated = w.raise('coder');
w.resolve(gated, 'yes', 'human');
assert.equal(w.held('coder'), false, 'gated');
const ok = w.raise();
w.resolve(ok);
assert.equal(w.held(), true);
assert.equal(w.held('pm'), false, 'another role');
assert.equal(w.held('merge-gate', 'thread-1'), false, 'another thread');
assert.throws(() => w.b.landHeld({ business: 'demo', role: 'merge-gate' }), /invalid-request/);
assert.throws(() => w.b.landHeld({ business: 'nope', role: 'merge-gate', thread: null }));
});
test('no run may write the action.allowed for git.merge.protected itself', (t) => {
const w = setup(t);
assert.throws(
() => w.b.recordEvent(w.caps.coder, { kind: 'action.allowed', body: { action: MERGE, target: TASK, commit: B }, subject: TASK }),
/reserved-event/,
);
});
@@ -14,8 +14,11 @@ test('Broker keeps the existing sandbox client import closure: no new imports or
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
"import { randomUUID, randomBytes } from 'node:crypto';",
"import { loadRequirements } from '../../tasks/src/requirements.mjs';",
// Row 137 (#1631): land-work's checks, in RUNNER_FILES beside the broker. They import nothing.
"import { MERGE, LandingRefusal, readLanding } from './landing.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
});
test('without a trusted project adapter all three async paths refuse project-not-configured', async t => {
+38 -6
View File
@@ -418,9 +418,12 @@ The launcher also serves `<dataRoot>/bus-host/git.sock` (0600,
`src/git-socket.mjs`, row 93, #1587). A coder's runner pushes work to the
business's working repository through it, and a reviewer's runner fetches a
commit it was asked to review (`push_working` and `fetch_review`,
`packages/harness/README.md`). The sandbox binds it only for a role that
holds git.push.working or review.verdict, and a session's `session.json`
names it as `gitSocket` only then.
`packages/harness/README.md`). A role granted git.merge.protected
cross-role whose merge decision was approved, by its arbiter or the human,
lands its task on the repository's `main` (`land_work`, row 137, #1631). The sandbox binds the socket only for a role that holds
git.push.working or review.verdict, or one the broker's `landHeld` says
holds a merge decision it could land. A session's `session.json` names it
as `gitSocket` only then.
Only a git bundle crosses. The host never runs git in a session's clone
and never opens a path in its workspace; `packages/seat/README.md` ("The
@@ -431,11 +434,12 @@ JSON line of at most 4096 bytes:
|---|---|---|
| push | `{cap, op: "push", task_ref, project, branch, size}` | `{ok: true, send: <size>}`; the runner sends exactly that many bundle bytes; then `{ok: true, result: {ref, commit, previous}}` |
| fetch | `{cap, op: "fetch", task_ref, request, have}` | `{ok: true, result: {project, ref, commit, size}}`, then that many bytes of a bundle whose one ref is `refs/mosaic/review` |
| land | `{cap, op: "land", task_ref, decision}` | `{ok: true, result: {landing: "started", commit, base, reviewed, patch_id}}` once every check before the gate passed; `commit` is the squash that will land |
The host checks the caller's identity (a role of this business), then the
request (task ref, project, branch name, a size of at most 32 MiB), then
the broker: `checkPush` for a push, `reviewFetch` for a fetch. Only
then does it read bundle bytes. The broker records a push with
the broker: `checkPush` for a push, `reviewFetch` for a fetch,
`checkLand` for a landing. Only then does it read bundle bytes. The broker records a push with
`recordPush` under the repository's lock once the ref moved; when it
refuses, the ref goes back. A refusal is `{ok: false, error: <code>}` and
ends the connection. The host's own refusals go through the broker's
@@ -461,11 +465,39 @@ Codes a reviewer may see that aren't the broker's:
`rollback-failed` (a push the broker refused whose work ref couldn't go
back) are the host's own faults, and the host logs them as well.
A landing runs land-work's steps (`scripts/land-work.sh`) against the
working repository's `main`, never the project root:
1. The broker's `checkLand` checks the decision and the verdict and gives
the host a ticket (`packages/bus/README.md`).
2. `prepareLand` builds the squash in a worktree under
`bus-host/land/<id>/` (`packages/seat/README.md`, "Landing on main").
The message is land-work's. Its Resolved line names the channel from
the ticket's `via`: the human via the CLI, or the arbiter via the broker.
3. The project root has to be a mosaic-stack checkout with `node_modules`
(`isMosaicStack`), or `gate-missing`. The root's `node_modules` is
linked into the worktree.
4. The host answers `started`, and the gate runs in the background, in the
squash's worktree, with `gateEnv`'s variables only.
5. `finishLand` moves `main` and the broker's `recordLand` records it.
One landing runs at a time; another refuses `land-busy`. From the ticket
on, every refusal goes to the broker's `landRefused`, on the task's trail.
A refusal before the `started` answer is also the reply. After it, the role
learns the outcome as a RESULT message from the bus: the landing, or
`gate-failed` (naming the step and its log), `base-moved`, a broker
refusal, or `host-closing` when the launcher closed during the gate, in
which case nothing moved. The logs stay in `bus-host/land/<id>/`. The
worktree and the gate's Docker network, `<id>_default`, go on every path.
`createGitSocket({gate})` takes the gate's three parts (`LAND_GATE`:
`classify`, `run`, `dropNetwork`) so the tests can run a fake one.
A bundle file lives in `bus-host/` (0700) under a random name and is
deleted on every path. On close the launcher answers `host-closing` to a
connection still sending its request line, since nothing of it ran yet,
and drops every other connection. It waits for any push in flight to finish
under its lock before the broker stops. It waits at most 2 s
under its lock before the broker stops, and stops a running gate, whose
landing is refused `host-closing`. It waits at most 2 s
(`CLOSE_WAIT_MS`) for its `host-closing` answers to leave, then drops those
connections too, so a peer that never reads can't hold the close.
+130 -10
View File
@@ -1,10 +1,12 @@
// The host's git socket (#1587), `<dataRoot>/bus-host/git.sock` (0600). A
// coder's runner pushes its work to the business's working repository
// through it, and a reviewer's runner fetches a commit it was asked to
// review. The bundle is the only thing that crosses: the host never runs git
// in a session's clone and never opens a path in a session's workspace
// (packages/seat/src/working.mjs says why). The sandbox binds the socket only
// for a role that holds git.push.working or review.verdict.
// through it, a reviewer's runner fetches a commit it was asked to review,
// and a run holding an approved merge decision lands its task on the
// repository's main (row 137, #1631). The bundle is the only thing that
// crosses: the host never runs git in a session's clone and never opens a
// path in a session's workspace (packages/seat/src/working.mjs says why). The
// sandbox binds the socket only for a role that holds git.push.working or
// review.verdict, or a merge decision a landing could use (landHeld).
//
// Every connection starts with one JSON line of at most 4096 bytes.
//
@@ -17,6 +19,13 @@
// The host answers {"ok": true, "result": {project, ref, commit,
// size}} followed by <size> bytes of a bundle whose one ref is
// refs/mosaic/review, or a refusal.
// land {"cap", "op": "land", "task_ref", "decision"}
// The host answers {"ok": true, "result": {landing: "started",
// commit, base, reviewed, patch_id}} once everything that can
// refuse before the gate has passed, or a refusal. `commit` is the
// squash that will land. The gate runs after the answer, and its
// outcome is the broker's: recordLand or landRefused, each on the
// task's trail and told to the role as a RESULT message.
//
// A refusal is {"ok": false, "error": "<code>"}, and the connection ends.
// The host checks, in order: identity (a role of this business), the request
@@ -41,13 +50,23 @@
//
// A bundle file lives in bus-host/ (0700) under a random name, and the host
// deletes it on every path.
//
// A landing runs land-work's steps (scripts/land-work.sh): the broker's
// checkLand, then prepareLand (working.mjs) under the repository's lock, the
// gate check (gate-missing), the gate in the squash's worktree with the
// root's node_modules linked, and finishLand, which moves main and records.
// One landing runs at a time; another refuses land-busy. Its logs stay in
// bus-host/land/<id>/, and its worktree and Docker network go on every path.
// close() stops a running gate, and the landing is refused host-closing.
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { closeSync, createReadStream, existsSync, lstatSync, openSync, rmSync, statSync, writeSync, chmodSync } from "node:fs";
import { closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, rmSync, statSync, symlinkSync, writeSync, chmodSync } from "node:fs";
import { createServer } from "node:net";
import { join } from "node:path";
import { gitSocketPath } from "../../seat/src/session.mjs";
import { BUNDLE_MAX, FSCK, HAVE_MAX, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { CliError } from "./errors.mjs";
const HEADER_MAX = 4096;
@@ -217,9 +236,43 @@ export async function letGo(sockets, ms = CLOSE_WAIT_MS) {
for (const s of sockets) s.destroy();
}
// Removes the Docker network a gate's Compose project left. The project is
// named after the worktree, so the network is <name>_default.
function dropNetwork(name, env) {
return new Promise((resolve) => {
const child = spawn("docker", ["network", "rm", name], { env, stdio: "ignore" });
child.on("error", () => resolve());
child.on("close", () => resolve());
});
}
export const LAND_GATE = { classify: isMosaicStack, run: runGate, dropNetwork };
// How the decision was resolved, by the resolution's via: the human through the CLI, or the
// arbiter through the broker (row 137, REQ-DEC-2). readLanding takes no other.
const RESOLVED_VIA = { cli: "via the CLI", broker: "as its arbiter, via the broker" };
// The squash's message: land-work's, applied under the git socket.
function landMessage(f, { old, base, patch_id }) {
return `${f.subject}
Squash of ${f.work_ref} (${f.task_ref}) at ${f.commit} onto main ${old},
the reviewed diff ${base}..${f.commit}. Resolved ${f.choice} by ${f.by} ${RESOLVED_VIA[f.via] ?? `via ${f.via}`} at ${f.resolved_at}.
Decision: ${f.decision}
Verdict: ${f.verdict}
Work-ref: ${f.work_ref}
Reviewed-commit: ${f.commit}
Base: main ${old}
Patch-id: ${patch_id}
Gate: full, every package suite and every scripts/test-*.sh
Applied-under: git socket land (bus host)
`;
}
// host: startHost()'s handle; business: loadBusiness() output. bundleMax,
// bodyDeadline, bodyIdle, maxActive and fsck are for the tests.
export function createGitSocket({ host, business: b, dataRoot, env = process.env, log = () => {}, bundleMax = BUNDLE_MAX, bodyDeadline: deadlineOf = bodyDeadline, bodyIdle = BODY_IDLE_MS, maxActive = MAX_ACTIVE, fsck = FSCK }) {
// bodyDeadline, bodyIdle, maxActive, fsck and gate (LAND_GATE's shape) are
// for the tests.
export function createGitSocket({ host, business: b, dataRoot, env = process.env, log = () => {}, bundleMax = BUNDLE_MAX, bodyDeadline: deadlineOf = bodyDeadline, bodyIdle = BODY_IDLE_MS, maxActive = MAX_ACTIVE, fsck = FSCK, gate = LAND_GATE }) {
const path = gitSocketPath(dataRoot);
const dir = join(dataRoot, "bus-host");
let server = null;
@@ -228,6 +281,8 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
const active = new Set();
// Connections still reading their request line (readHeader).
const waiting = new Map();
// The running landing, { abort, done }, or null.
let landing = null;
const op = async (message) => {
try {
@@ -270,6 +325,67 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
await send(socket, file);
}
async function doLand(socket, cap, req) {
const { task_ref, decision } = req;
if (typeof decision !== "string") throw new Refusal("invalid-request");
workRef(task_ref);
if (landing) throw new Refusal("land-busy", "another landing is running");
// Taken before the first await, so two requests can't both pass.
const slot = { abort: new AbortController(), done: null };
landing = slot;
try {
await startLand(socket, cap, task_ref, decision, slot);
} finally {
if (!slot.done) landing = null;
}
}
// From the broker's ticket on, each refusal is the landing's (landRefused):
// the run's cap may be gone by the time the gate ends.
async function startLand(socket, cap, task_ref, decision, slot) {
const facts = await op({ op: "checkLand", cap, args: { task_ref, decision } });
const { ticket } = facts;
const id = `land-${randomBytes(6).toString("hex")}`;
const logs = join(dir, "land", id);
const p = project(facts.project);
const where = { dataRoot, business: b.id, project: facts.project, env };
const refused = async (e, notify) => {
const code = e instanceof Refusal || e instanceof Recorded || e instanceof WorkingError ? e.code : "git-failed";
if (code === "git-failed" || code === "rollback-failed") log(`git socket: ${id}: ${code}: ${e.message}`);
// The host's own faults stay in its log; the role gets the code.
const detail = notify && code !== "git-failed" && e.message !== code ? e.message.slice(0, 1000) : null;
await host.op({ op: "landRefused", ticket, args: { code, detail, notify } }).catch((x) => log(`git socket: ${id}: refusal ${code} not recorded: ${x.code ?? x.message}`));
return code;
};
let prepared = null;
try {
if (!p) throw new Refusal("unknown-project");
mkdirSync(logs, { recursive: true, mode: 0o700 });
prepared = await prepareLand({ ...where, root: p.root, taskRef: task_ref, commit: facts.commit, dir: logs, name: id, rootHits, message: (m) => landMessage(facts, m) });
if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `${p.root} isn't a mosaic-stack checkout with node_modules`);
symlinkSync(join(p.root, "node_modules"), join(prepared.worktree, "node_modules"));
} catch (e) {
if (prepared) await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
throw new Recorded(await refused(e, false));
}
const { commit, base, reviewed, patch_id } = prepared;
socket.end(`${JSON.stringify({ ok: true, result: { landing: "started", commit, base, reviewed, patch_id } })}\n`);
slot.done = (async () => {
try {
const r = await gate.run({ worktree: prepared.worktree, logs, env: gateEnv(env), signal: slot.abort.signal });
if (r.aborted || slot.abort.signal.aborted) throw new Refusal("host-closing", "the host closed during the gate; nothing moved");
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see ${r.log}`);
await finishLand({ ...where, land: prepared, record: (m) => op({ op: "recordLand", ticket, land: { ...m, reviewed, base, patch_id } }) });
} catch (e) {
await refused(e, true);
} finally {
await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
await gate.dropNetwork(`${id}_default`, gateEnv(env));
landing = null;
}
})();
}
async function serve(socket) {
let cap = null;
let subject = null;
@@ -293,7 +409,8 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
file = join(dir, `${randomBytes(8).toString("hex")}.bundle`);
if (req.op === "push") await doPush(socket, cap, req, rest, file);
else if (req.op === "fetch") await doFetch(socket, cap, req, file);
else throw new Refusal("invalid-request", "op is push or fetch");
else if (req.op === "land") await doLand(socket, cap, req);
else throw new Refusal("invalid-request", "op is push, fetch or land");
} catch (e) {
if (e instanceof Recorded) return fail(e.code);
const code = e instanceof Refusal || e instanceof WorkingError ? e.code : "git-failed";
@@ -330,6 +447,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
// A push whose bundle already came finishes, under its lock, while the
// broker still runs: the launcher's close() runs before the host stops it.
// A connection still waiting for its request line hears host-closing first.
// A landing's gate is stopped, and its refusal recorded the same way.
async function close() {
closed = true;
if (!server) return;
@@ -337,7 +455,9 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
const told = new Set(waiting.keys());
for (const stop of [...waiting.values()]) stop();
for (const socket of sockets) if (!told.has(socket)) socket.destroy();
landing?.abort.abort();
await Promise.allSettled([...active]);
await landing?.done;
// The refusal is a few bytes; once it is flushed, or the peer is gone,
// nothing more is owed, and past CLOSE_WAIT_MS it is dropped unsent.
await letGo([...told]);
+8 -3
View File
@@ -38,7 +38,8 @@
//
// listen() also serves the git socket (src/git-socket.mjs, row 93), which
// the sandbox binds for a role that holds git.push.working or
// review.verdict; session.json names it as gitSocket.
// review.verdict, or a merge decision to land (landHeld, row 137);
// session.json names it as gitSocket.
//
// Thread sessions (row 121, #1615). A thread instance (an instance whose
// role definition is the assistant) runs once per thread, never on its own:
@@ -256,6 +257,9 @@ export function createLauncher({
const toolSocket = join(runDir, "tools.sock");
const turnMarker = join(runDir, "turn.done");
const v = versions ?? { pi: piVersion(), claude: model.harness === "claude-code" ? claudeVersion(env) : null };
// Row 137 (#1631): a role and thread holding an approved, unspent merge decision get land_work.
// The broker checks the decision again on every land; this only decides what the bundle shows.
const land = await host.op({ op: "landHeld", record: { business: b.id, role: instance, thread } });
const bundle = buildBundle({
dir: join(runDir, "bundle"),
resolved,
@@ -267,11 +271,12 @@ export function createLauncher({
session: { run, launchedBy, workspace, toolSocket, turnMarker, checkouts },
piVersion: v.pi,
claudeVersion: v.claude,
land,
});
const { manifest, ...files } = bundle.files;
// Row 93: the git socket, for a role that pushes work or reviews it.
// Row 93: the git socket, for a role that pushes work or reviews it. Row 137: or lands it.
const held = [...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole];
const git = held.includes("git.push.working") || held.includes("review.verdict") ? gitSocketPath(dataRoot) : null;
const git = land || held.includes("git.push.working") || held.includes("review.verdict") ? gitSocketPath(dataRoot) : null;
writeSessionFile(runDir, {
...sessionDefaults,
business: b.id,
+214 -5
View File
@@ -20,9 +20,10 @@ import { configDir, loadBusiness } from "../../business/src/index.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { gitSocketPath } from "../../seat/src/session.mjs";
import { workingRepo } from "../../seat/src/working.mjs";
import { fetchReview, pushWorking } from "../../harness/src/git-tools.mjs";
import { fetchReview, landWork, pushWorking } from "../../harness/src/git-tools.mjs";
import { gateEnv, isMosaicStack, runGate } from "../../seat/src/landing.mjs";
import { bootConfig, loadSystem, rolesDir } from "../src/config.mjs";
import { CLOSE_WAIT_MS, createGitSocket, letGo, send } from "../src/git-socket.mjs";
import { CLOSE_WAIT_MS, createGitSocket, LAND_GATE, letGo, send } from "../src/git-socket.mjs";
import { startHost, startTimeOf } from "../src/host.mjs";
import { fixture } from "./helpers.mjs";
@@ -158,7 +159,7 @@ test("the review loop: the coder pushes and asks, the reviewer fetches and appro
const c1 = commit(coder.clone, "two");
assert.deepEqual(await w.push("coder"), { ref: "work/vikunja-1-2", commit: c1, previous: null });
assert.equal(gitIn(coder.clone, "rev-parse", "refs/remotes/working/work/vikunja-1-2"), c1);
assert.equal(w.refs(), `refs/heads/main ${w.main}\nrefs/heads/work/vikunja-1-2 ${c1}`);
assert.equal(w.refs(), `refs/heads/main ${w.main}\nrefs/heads/root ${w.main}\nrefs/heads/work/vikunja-1-2 ${c1}`);
// Nothing new: the clone remembers the push.
await rejects(w.push("coder"), "nothing-to-push");
@@ -286,7 +287,7 @@ test("the socket refuses a request with no capability, an unknown one, and a bad
s.on("error", reject);
});
assert.deepEqual(JSON.parse(junk), { ok: false, error: "invalid-bundle" });
assert.equal(w.refs(), `refs/heads/main ${w.main}`);
assert.equal(w.refs(), `refs/heads/main ${w.main}\nrefs/heads/root ${w.main}`);
});
test("a rigged clone runs nothing: hooks, fsmonitor, a filter, a gitlink and a symlinked .git/config leave no marker", async (t) => {
@@ -620,9 +621,217 @@ test("a record that fails and can't be undone refuses rollback-failed, which the
const c1 = commit(coder.clone, "two");
lock = join(w.repo, "refs", "heads", "work", "vikunja-1-2.lock");
await rejects(w.push("coder"), "rollback-failed");
assert.equal(w.refs(), `refs/heads/main ${w.main}\nrefs/heads/work/vikunja-1-2 ${c1}`);
assert.equal(w.refs(), `refs/heads/main ${w.main}\nrefs/heads/root ${w.main}\nrefs/heads/work/vikunja-1-2 ${c1}`);
assert.equal(w.logs.length, 1);
assert.match(w.logs[0], /^git socket: rollback-failed: refs\/heads\/work\/vikunja-1-2 still names /);
assert.ok((await refusedOn(w)).includes("coder rollback-failed"));
rmSync(lock);
});
// Row 137 (#1631): the land op. The broker's side is a stand-in: checkLand
// needs a cross-role merge decision its arbiter or the human resolved
// (packages/bus/tests/land.test.mjs covers it). The
// working repository, prepareLand, finishLand and dropLand are real, and the
// gate is a stand-in the test passes as `gate`.
const TICKET = "0b1c2d3e-0000-4000-8000-000000000000";
async function landSetup(t, { gate = {}, ...opts } = {}) {
const calls = [];
const state = { facts: null, refuse: null, recordFails: null };
const w = await setup(t, {
gate: {
classify: async (root, env) => (calls.push(["classify", root, env]), true),
run: async () => ({ ok: true }),
dropNetwork: async (name, env) => calls.push(["dropNetwork", name, env]),
...gate,
},
wrap: (host) => ({
op: async (m) => {
if (m.op === "checkLand") {
calls.push(["checkLand", m.args]);
if (state.refuse) throw Object.assign(new Error(state.refuse), { code: state.refuse });
return { ...state.facts, ticket: TICKET };
}
if (m.op === "recordLand") {
calls.push(["recordLand", m.ticket, m.land]);
if (state.recordFails) throw Object.assign(new Error(state.recordFails), { code: state.recordFails });
return { task_ref: TASK, commit: m.land.commit };
}
if (m.op === "landRefused") return calls.push(["landRefused", m.ticket, m.args]), { task_ref: TASK };
return host.op(m);
},
}),
...opts,
});
gitIn(w.roles.coder.clone, "checkout", "-q", "-b", "feat");
const c1 = commit(w.roles.coder.clone, "two");
await w.push("coder");
state.facts = {
task_ref: TASK, work_ref: "work/vikunja-1-2", decision: "decision-1", project: "stack", ref: "work/vikunja-1-2", commit: c1,
verdict: "verdict-1", subject: "Land the parser fix", choice: "yes", by: "jason", via: "cli",
resolved_at: "2026-10-16T00:00:00.000Z",
};
const land = () => landWork({ path: w.sock.path, cap: w.roles.coder.cap, task_ref: TASK, decision: "decision-1" });
const git = (...args) => gitIn(w.root, `--git-dir=${w.repo}`, ...args);
// Worktrees other than the bare repository itself.
const trees = () => git("worktree", "list", "--porcelain").split("\n").filter((l) => l.startsWith("worktree ")).length - 1;
const of = (kind) => calls.filter(([k]) => k === kind).map(([, ...rest]) => rest);
const ended = () => until(() => of("dropNetwork").length, "the landing's end");
return { ...w, calls, state, c1, land, git, trees, of, ended };
}
test("land: the squash runs the gate in its own worktree, then main moves and the broker records it", async (t) => {
const seen = [];
const w = await landSetup(t, {
gate: {
run: async (a) => {
seen.push({ ...a, head: gitIn(a.worktree, "rev-parse", "HEAD"), file: readFileSync(join(a.worktree, "file.txt"), "utf8"), link: readlinkSync(join(a.worktree, "node_modules")), status: gitIn(a.worktree, "status", "--porcelain", "--ignored=no") });
return { ok: true };
},
},
});
const r = await w.land();
assert.equal(r.landing, "started");
assert.equal(r.reviewed, w.c1);
assert.equal(r.base, w.main);
assert.match(r.patch_id, /^[0-9a-f]{40}$/);
await w.ended();
// main is the squash: one parent, the old main, and the reviewed commit's tree.
assert.equal(w.git("rev-parse", "refs/heads/main"), r.commit);
assert.equal(w.git("rev-parse", `${r.commit}^@`), w.main);
assert.equal(w.git("rev-parse", `${r.commit}^{tree}`), w.git("rev-parse", `${w.c1}^{tree}`));
const msg = w.git("show", "-s", "--format=%B", r.commit);
assert.match(msg, /^Land the parser fix\n/);
assert.ok(msg.includes(" Resolved yes by jason via the CLI at 2026-10-16T00:00:00.000Z.\n"), msg);
for (const line of [`Decision: decision-1`, `Verdict: verdict-1`, `Reviewed-commit: ${w.c1}`, `Base: main ${w.main}`, `Patch-id: ${r.patch_id}`]) assert.ok(msg.includes(`\n${line}\n`), line);
// w.git trims, so the last line has no newline after it.
assert.match(msg, /\nApplied-under: git socket land \(bus host\)$/);
// The gate ran once, at the squash, with the root's node_modules and the trimmed environment.
assert.equal(seen.length, 1);
const [g] = seen;
assert.equal(g.head, r.commit);
assert.equal(g.file, "two\n");
assert.equal(g.link, join(w.project, "node_modules"));
// Nothing but the link: the fixture project has no .gitignore for it.
assert.equal(g.status, "?? node_modules");
assert.deepEqual(g.env, gateEnv(w.f.env));
assert.equal(g.signal.aborted, false);
const id = g.worktree.split("/").pop();
assert.match(id, /^land-[0-9a-f]{12}$/);
assert.equal(g.worktree, join(w.f.dataRoot, "bus-host", "land", id, id));
assert.equal(g.logs, join(w.f.dataRoot, "bus-host", "land", id));
assert.ok(existsSync(join(g.logs, "reviewed.patch")), "the landing's files stay in its log directory");
assert.deepEqual(w.of("classify"), [[w.project, w.f.env]]);
assert.deepEqual(w.of("checkLand"), [[{ task_ref: TASK, decision: "decision-1" }]]);
assert.deepEqual(w.of("recordLand"), [[TICKET, { commit: r.commit, previous: w.main, reviewed: w.c1, base: w.main, patch_id: r.patch_id }]]);
assert.deepEqual(w.of("landRefused"), []);
assert.deepEqual(w.of("dropNetwork"), [[`${id}_default`, gateEnv(w.f.env)]]);
assert.equal(existsSync(g.worktree), false);
assert.equal(w.trees(), 0);
assert.deepEqual(await refusedOn(w), []);
assert.deepEqual(w.logs, []);
});
// Row 137 B1: the squash names the channel that resolved the decision. An arbiter's
// resolution came through the broker, not the CLI.
test("land: an arbiter-resolved landing's squash says the arbiter resolved it through the broker", async (t) => {
const w = await landSetup(t);
w.state.facts = { ...w.state.facts, by: "pm-run", via: "broker" };
const r = await w.land();
await w.ended();
const msg = w.git("show", "-s", "--format=%B", r.commit);
assert.ok(msg.includes(" Resolved yes by pm-run as its arbiter, via the broker at 2026-10-16T00:00:00.000Z.\n"), msg);
assert.ok(!msg.includes("via the CLI"), msg);
});
test("land: refusals before the gate answer the role at once and leave main and no worktree", async (t) => {
// Until `fit` goes false the gate fits; then the host's own check decides,
// and the fixture's project is no mosaic-stack checkout.
let fit = true;
const w = await landSetup(t, { gate: { classify: async (root, env) => fit || isMosaicStack(root, env) } });
assert.equal(LAND_GATE.classify, isMosaicStack);
assert.equal(LAND_GATE.run, runGate);
const before = w.refs();
// The broker's own refusal: it filed it, so the host files nothing more.
w.state.refuse = "decision-not-approved";
await rejects(w.land(), "decision-not-approved");
assert.deepEqual(w.of("landRefused"), []);
w.state.refuse = null;
// The work ref isn't where the verdict was.
w.state.facts = { ...w.state.facts, commit: "e".repeat(40) };
await rejects(w.land(), "ref-moved");
w.state.facts = { ...w.state.facts, commit: w.c1 };
// The project root isn't a mosaic-stack checkout, so it has no gate.
fit = false;
await rejects(w.land(), "gate-missing");
// A project the business no longer has.
w.state.facts = { ...w.state.facts, project: "gone" };
await rejects(w.land(), "unknown-project");
assert.deepEqual(
w.of("landRefused").map(([ticket, a]) => [ticket, a.code, a.detail, a.notify]),
[[TICKET, "ref-moved", null, false], [TICKET, "gate-missing", null, false], [TICKET, "unknown-project", null, false]],
);
// A request with no decision is the host's own refusal, on the task's trail.
assert.deepEqual(await raw(w.sock.path, `${JSON.stringify({ cap: w.roles.coder.cap, op: "land", task_ref: TASK })}\n`), { ok: false, error: "invalid-request" });
assert.ok((await refusedOn(w)).includes("coder invalid-request"));
assert.equal(w.refs(), before);
assert.equal(w.trees(), 0);
assert.deepEqual(w.of("recordLand"), []);
assert.deepEqual(w.of("dropNetwork"), []);
assert.deepEqual(w.logs, []);
});
test("land: a failed gate is refused gate-failed with its step, told to the role, and main stays", async (t) => {
const w = await landSetup(t, { gate: { run: async ({ logs }) => ({ ok: false, step: "pkg-bus", log: join(logs, "pkg-bus.log") }) } });
const r = await w.land();
await w.ended();
const [[ticket, a]] = w.of("landRefused");
assert.equal(ticket, TICKET);
assert.equal(a.code, "gate-failed");
assert.equal(a.notify, true);
assert.match(a.detail, /^pkg-bus failed, see \/.*\/bus-host\/land\/land-[0-9a-f]{12}\/pkg-bus\.log$/);
assert.equal(w.git("rev-parse", "refs/heads/main"), w.main);
assert.equal(w.git("cat-file", "-t", r.commit), "commit", "the squash was made, and only main didn't move");
assert.deepEqual(w.of("recordLand"), []);
assert.equal(w.trees(), 0);
});
test("land: a record the broker refuses moves main back, and the landing is refused with the broker's code", async (t) => {
const w = await landSetup(t);
w.state.recordFails = "decision-consumed";
await w.land();
await w.ended();
assert.equal(w.of("recordLand").length, 1);
assert.deepEqual(w.of("landRefused"), [[TICKET, { code: "decision-consumed", detail: null, notify: true }]]);
assert.equal(w.git("rev-parse", "refs/heads/main"), w.main);
assert.equal(w.trees(), 0);
assert.deepEqual(w.logs, []);
});
test("land: a second landing while one runs refuses land-busy, and close() stops the gate and refuses host-closing", async (t) => {
let started = null;
const running = new Promise((r) => (started = r));
const w = await landSetup(t, {
gate: {
run: ({ signal }) => {
started();
return new Promise((resolve) => signal.addEventListener("abort", () => resolve({ ok: false, aborted: true }), { once: true }));
},
},
});
await w.land();
await running;
await rejects(w.land(), "land-busy");
assert.ok((await refusedOn(w)).includes("coder land-busy"));
assert.deepEqual(w.of("checkLand").length, 1, "land-busy refuses before the broker is asked");
await w.sock.close();
assert.deepEqual(
w.of("landRefused").map(([, a]) => a),
[{ code: "host-closing", detail: "the host closed during the gate; nothing moved", notify: true }],
);
assert.equal(w.of("dropNetwork").length, 1);
assert.equal(w.git("rev-parse", "refs/heads/main"), w.main);
assert.equal(w.trees(), 0);
});
+33 -2
View File
@@ -80,7 +80,8 @@ function prepare(t, more = (doc) => doc) {
return { root, f, adapter };
}
async function setup(t, more = (doc) => doc, { root, f, adapter } = prepare(t, more), options = {}) {
// options.wrap(host) is what the launcher sees, so a test can change one of the host's answers.
async function setup(t, more = (doc) => doc, { root, f, adapter } = prepare(t, more), { wrap = (h) => h, ...options } = {}) {
const system = loadSystem({ env: f.env });
const boot = bootConfig({ system, businessId: "acme", env: f.env });
const logs = [];
@@ -88,7 +89,7 @@ async function setup(t, more = (doc) => doc, { root, f, adapter } = prepare(t, m
let launcher;
try {
launcher = createLauncher({
host,
host: wrap(host),
system,
env: f.env,
adapters: { pi: adapter, "claude-code": adapter },
@@ -382,6 +383,36 @@ test("each launch makes sure the workspace has a clone of the project: cloned, f
assert.equal(gitIn(project, "status", "--porcelain"), "");
});
// Row 137 (#1631): the chief holds neither git.push.working nor
// review.verdict, so only landHeld can give it land_work and the git socket.
// landHeld is the broker's (packages/bus/tests/land.test.mjs); here the host
// answers it, since a real answer needs a merge decision the human resolved
// through the CLI.
test("a role the broker says holds a merge decision gets land_work, its prompt line and the git socket; without it, none", { skip, timeout: 30000 }, async (t) => {
const asked = [];
const answering = (held) => ({ wrap: (host) => Object.create(host, { op: { value: async (m) => (m.op === "landHeld" ? (asked.push(m.record), held) : host.op(m)) } }) });
const landing = "- Landing: once your merge decision is approved, land_work it with the task.";
const read = (f, run, file) => readFileSync(join(f.dataRoot, "launches", "acme", run, file), "utf8");
const one = await setup(t, undefined, undefined, answering(true));
const pm1 = await one.launcher.launchPm();
assert.deepEqual(asked, [{ business: "acme", role: "chief", thread: null }]);
const tool = JSON.parse(read(one.f, pm1.run, "bundle/tools.json")).find((x) => x.name === "land_work");
assert.equal(tool.verb, "working.land");
assert.equal(tool.action, "git.merge.protected");
assert.deepEqual(tool.parameters.required, ["task_ref", "decision"]);
assert.ok(read(one.f, pm1.run, "bundle/prompt.md").includes(landing));
assert.equal(JSON.parse(read(one.f, pm1.run, "session.json")).gitSocket, gitSocketPath(one.f.dataRoot));
// A separate fixture, so its business and data root are new.
const two = await setup(t, undefined, undefined, answering(false));
const pm2 = await two.launcher.launchPm();
assert.equal(asked.length, 2);
assert.equal(JSON.parse(read(two.f, pm2.run, "bundle/tools.json")).some((x) => x.name === "land_work"), false);
assert.equal(read(two.f, pm2.run, "bundle/prompt.md").includes(landing), false);
assert.equal(JSON.parse(read(two.f, pm2.run, "session.json")).gitSocket, null);
});
test("a project clone that can't be made refuses checkout-failed, from --chief and from a session, with no run and no clone left", { skip, timeout: 30000 }, async (t) => {
const given = prepare(t);
renameSync(join(given.f.doc.projects.stack.root, ".git"), join(given.root, "away.git"));
@@ -84,6 +84,7 @@ async function start(t, { f, adapter }, { tickMs = 100, ops: replace } = {}) {
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => runtime.threadIdle(m.record),
landHeld: (m) => runtime.landHeld(m.record),
...replace?.(runtime),
};
const refusing = (what, fn) => async (m) => {
+16 -4
View File
@@ -136,6 +136,17 @@ the run's `/tmp` is deleted on every path. A session without a clone (no
verdict id `review_verdict` returned; for a task with pushed work the
broker takes only an `approve` verdict for the latest push.
Row 137 (#1631) adds `land_work` (verb `working.land`, action
`git.merge.protected`), between `push_working` and `request_review`. Only
a role the broker's `landHeld` says holds an approved merge decision gets
it, with or without a clone. It takes `task_ref` and `decision` and sends
one `land` line to the git socket. The reply is
`{landing: "started", commit, base, reviewed, patch_id}`, where `commit`
is the squash that will land on the working repository's `main`. The gate
runs after the reply, and the outcome comes later as a RESULT from the
bus: the landing, or the refusal code. The role's prompt says so in one
line. A second `land_work` while one runs refuses `land-busy`.
## The gate
`gate.mjs` `decide(policy, tool, input)` answers one tool call: the
@@ -194,8 +205,8 @@ it never touches the disk and isn't in the environment or argv.
usable role token, exits 20 before the role is claimed.
2. `role.claim`.
3. The tool socket serves the typed tools. `launch` goes to the host's
launch socket, `push_working` and `fetch_review` to its git socket, and
every other tool to the broker.
launch socket, `push_working`, `fetch_review` and `land_work` to its git
socket, and every other tool to the broker.
4. One harness turn per received message, through the adapter
(`/bin/sh <adapter.sh>`: the repository keeps adapters 0644 and only the
image sets the mode), in its own process group, under the wall clock
@@ -262,8 +273,9 @@ These are limits, not bugs, and nothing in this package claims otherwise.
tool socket is in the run directory and acts with the session's own
capability only. The launch socket is bound in only for the role whose
launch tool calls it. The git socket is bound in only for a role that
holds git.push.working or review.verdict, and every request on it needs
a capability the broker then checks.
holds git.push.working or review.verdict, or holds a merge decision it
could land, and every request on it needs a capability the broker then
checks.
- **Git tool timeouts disagree.** git in `git-tools.mjs` may run for 300 s,
but the tool call itself (`callTool`) gives up after 60 s and the tool
socket after 150 s. A push or fetch slower than 60 s reaches the model
+6 -3
View File
@@ -114,6 +114,8 @@ function prompt({ contract, resolved, session, tools, untyped }) {
// Row 93 (#1587): the review loop, for the roles that have its tools.
if (named.has("push_working"))
lines.push("- Review loop: commit on a branch of your clone, push_working it, then request_review with the commit it returned. Work that isn't pushed can't be reviewed, and a task with pushed work closes only on an approve verdict for its latest push. After a changes verdict, commit the fixes, push again and ask again.");
if (named.has("land_work"))
lines.push("- Landing: once your merge decision is approved, land_work it with the task. The reply only says the landing started; the gate's outcome comes later as a RESULT from the bus. Don't land again while one is running.");
if (named.has("fetch_review"))
lines.push("- Reviews: a REVIEW-REQUEST names a request id. fetch_review it, read the commit in your clone, and answer with review_verdict citing the request. One verdict per request.");
if (named.has("web_search"))
@@ -127,7 +129,7 @@ function prompt({ contract, resolved, session, tools, untyped }) {
return lines.join("\n") + "\n";
}
// buildBundle({ dir, resolved, contract, arbiter, tracker, credentials, session, piVersion, claudeVersion })
// buildBundle({ dir, resolved, contract, arbiter, tracker, credentials, session, piVersion, claudeVersion, land })
// dir the bundle directory, created here (0700)
// resolved resolveInstance(...) output
// contract the role contract's text
@@ -139,11 +141,12 @@ function prompt({ contract, resolved, session, tools, untyped }) {
// named in the prompt; none when absent
// piVersion the pinned pi version, for the manifest
// claudeVersion the host's `claude --version`, for the manifest
// land true when the role and thread hold a merge decision to land (row 137)
// Returns { manifest, files } with absolute paths.
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, piVersion = null, claudeVersion = null, models = null }) {
export function buildBundle({ dir, resolved, contract, arbiter = false, tracker = false, credentials = [], session, piVersion = null, claudeVersion = null, models = null, land = false }) {
const model = sessionModel(resolved, models);
const web = webPlan(resolved, model.harness);
const tools = typedTools({ resolved, arbiter, tracker, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin });
const tools = typedTools({ resolved, arbiter, tracker, projects: (session.checkouts ?? []).map((c) => c.project), webTyped: !web.builtin, land });
const untyped = untypedActions(resolved, tools);
mkdirSync(dir, { recursive: true, mode: 0o700 });
const write = (name, text) => {
+19 -2
View File
@@ -1,7 +1,8 @@
// The session side of the host's git socket (#1587,
// packages/cli/src/git-socket.mjs). The runner calls these for the
// push_working and fetch_review tools, inside the sandbox, in the session's
// own clones under its workspace. Commits leave and arrive only as bundles:
// push_working, fetch_review and land_work tools, inside the sandbox, in the
// session's own clones under its workspace. Commits leave and arrive only as
// bundles:
//
// push `git bundle create` of the named branch, leaving out what the
// clone's origin and working remotes already have, written to the
@@ -9,6 +10,8 @@
// fetch the host sends a bundle of the commit the review request names;
// the runner fetches it with fsck on into
// refs/remotes/working/<work ref> of that project's clone.
// land no bundle and no clone: the host lands the reviewed commit it
// already has and answers once the gate has started.
//
// The bundle files are deleted on every path. git runs with no system or
// global config and no hooks, as the host's does.
@@ -192,6 +195,20 @@ export async function pushWorking({ path, cap, workspace, task_ref, project, bra
}
}
// await landWork({ path, cap, task_ref, decision })
// Resolves the host's { landing: "started", commit, base, reviewed, patch_id }.
export async function landWork({ path, cap, task_ref, decision }) {
const conn = open(path);
try {
conn.socket.write(`${JSON.stringify({ cap, op: "land", task_ref, decision })}\n`);
const result = refused(await conn.line()).result;
if (result?.landing !== "started" || !["commit", "base", "reviewed"].every((k) => OID.test(result[k] ?? ""))) throw failure("invalid-response");
return result;
} finally {
conn.close();
}
}
// await fetchReview({ path, cap, workspace, task_ref, request, tmp })
// Resolves { project, ref, commit, clone }; ref is the clone's remote ref.
export async function fetchReview({ path, cap, workspace, task_ref, request, tmp = "/tmp", env = process.env }) {
+4 -3
View File
@@ -9,8 +9,8 @@
// 2. role.claim; a refusal exits 21.
// 3. The tool socket (0600) serves the harness's typed tools: a name in
// tools.json is forwarded with the runner's capability, to the broker
// or, for `launch`, to the host's launch socket, or, for push_working
// and fetch_review, to the host's git socket (git-tools.mjs). The
// or, for `launch`, to the host's launch socket, or, for push_working,
// fetch_review and land_work, to the host's git socket (git-tools.mjs). The
// harness never sees the capability.
// 4. One harness turn per received message, through the adapter, in its
// own process group under a wall clock (S0 line 4). The turn's stdout
@@ -31,7 +31,7 @@ import { pathToFileURL } from "node:url";
import { Client } from "../../bus/src/client.mjs";
import { SOURCES } from "./bundle.mjs";
import { claudeBuiltins } from "./gate.mjs";
import { fetchReview, pushWorking } from "./git-tools.mjs";
import { fetchReview, landWork, pushWorking } from "./git-tools.mjs";
export const EXIT = Object.freeze({ stopped: 0, founder: 20, claim: 21, session: 22, broker: 23, usage: 2 });
@@ -328,6 +328,7 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
launch: (args) => launchCall(session.launchSocket, { cap, instance: args.instance }),
"working.push": (args) => pushWorking({ path: session.gitSocket, cap, workspace: session.workspace, task_ref: args.task_ref, project: args.project, branch: args.branch }),
"working.fetch": (args) => fetchReview({ path: session.gitSocket, cap, workspace: session.workspace, task_ref: args.task_ref, request: args.request }),
"working.land": (args) => landWork({ path: session.gitSocket, cap, task_ref: args.task_ref, decision: args.decision }),
},
});
const turnEnv = adapterEnv(session, env);
+13 -5
View File
@@ -1,6 +1,6 @@
// Typed tools: the only way a managed session acts on the bus. Each tool
// maps one-to-one onto a broker verb (or, for `launch`, onto the host's
// launch socket, and for push_working and fetch_review, onto the host's git
// launch socket, and for push_working, fetch_review and land_work, onto the host's git
// socket through git-tools.mjs), and a session gets a tool only for an action its resolved
// instance holds within-role or cross-role, plus the reads and
// raise_decision every agent has. The broker still decides every call;
@@ -77,16 +77,17 @@ const TASK_TOOLS = {
const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESULT", "RESULT", "INFO", "DECISION", "REACTION"];
// typedTools({ resolved, arbiter, tracker, projects }) -> [{ name, verb, action, description, parameters }]
// typedTools({ resolved, arbiter, tracker, projects, land }) -> [{ name, verb, action, description, parameters }]
// resolved resolveInstance(...) output for the session's instance
// arbiter true when the business names this instance as an arbiter
// tracker true when the broker has task verbs for the business
// projects the project ids the session has a clone of
// land true when the role and thread hold a merge decision a landing could use (landHeld)
// webTyped false when the harness's own web tools serve a granted role (search-source "harness-builtin")
// `verb` is the broker verb; the `launch` tool has verb "launch" and goes
// to the host instead, and "working.push" and "working.fetch" go to the
// host's git socket.
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true }) {
// to the host instead, and "working.push", "working.fetch" and
// "working.land" go to the host's git socket.
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false }) {
const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]);
const tools = [];
const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters });
@@ -129,6 +130,13 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
branch: str("Local branch to push, such as the branch you committed on"),
}, ["task_ref", "project", "branch"]));
}
// Row 137 (#1631): landing approved work. The broker rechecks the decision on every call.
if (land) {
add("land_work", "working.land", "git.merge.protected", "Land the task's reviewed work on main in the business's working repository, under the approved git.merge.protected decision you raised for it. The host checks the decision and the latest approving verdict, squashes the reviewed diff, and answers \"started\" with the commit that will land. The full test gate runs after that, and its outcome comes to you as a RESULT message from the bus: landed, or refused with the reason. One landing runs at a time; land-busy means another is running.", obj({
task_ref: TASK_REF,
decision: str("Id of the resolved git.merge.protected decision"),
}, ["task_ref", "decision"]));
}
if (held.has("review.request")) {
add("request_review", "review.request", "review.request", "Ask a reviewer role to review the task's latest pushed commit. The reviewer gets a REVIEW-REQUEST message, and its verdict comes back to you as a REVIEW-RESULT. The reply's held names the run holding the reviewer role, or is null when no reviewer is running. A null request waits until a reviewer launches, so tell whoever launches roles that a review is waiting.", obj({
task_ref: TASK_REF,
+9
View File
@@ -183,6 +183,15 @@ test("the prompt explains the review loop to a role with push_working, and revie
assert.deepEqual(lines(build(t, resolvedFor(t, "coder"))), []);
});
// Row 137 (#1631): the landing line comes with land_work, and only with it.
test("the prompt explains landing to a role with land_work, and to no other", (t) => {
const lines = (b) => readFileSync(b.files.prompt, "utf8").split("\n").filter((l) => /^- Landing: /.test(l));
const land = build(t, resolvedFor(t, "coder"), { land: true });
assert.deepEqual(lines(land), ["- Landing: once your merge decision is approved, land_work it with the task. The reply only says the landing started; the gate's outcome comes later as a RESULT from the bus. Don't land again while one is running."]);
assert.ok(JSON.parse(readFileSync(land.files.tools, "utf8")).some((x) => x.name === "land_work"));
assert.deepEqual(lines(build(t, resolvedFor(t, "coder"))), []);
});
// Row 140 (#1634): the pm's prompt names request_staff; no other role's does.
test("the prompt tells the pm to ask the chief of staff for sessions, and no other role", (t) => {
const staff = (b) => readFileSync(b.files.prompt, "utf8").split("\n").filter((l) => /^- Staff: /.test(l));
+23 -4
View File
@@ -8,7 +8,7 @@ import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { createServer } from "node:net";
import { join } from "node:path";
import { fetchReview, git as runnerGit, HAVE_MAX } from "../src/git-tools.mjs";
import { fetchReview, git as runnerGit, HAVE_MAX, landWork } from "../src/git-tools.mjs";
import { scratch } from "./helpers.mjs";
const ENV = { PATH: process.env.PATH, HOME: "/nonexistent", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", LC_ALL: "C" };
@@ -34,8 +34,8 @@ function clone(dir, refs) {
return oids;
}
// A socket that keeps each request line and refuses it.
async function fakeHost(t, dir) {
// A socket that keeps each request line and answers it with `reply`, a refusal by default.
async function fakeHost(t, dir, reply = { ok: false, error: "request-not-found" }) {
const path = join(dir, "git.sock");
const lines = [];
const server = createServer((s) => {
@@ -44,7 +44,7 @@ async function fakeHost(t, dir) {
buf += b;
if (!buf.includes("\n")) return;
lines.push(JSON.parse(buf.split("\n")[0]));
s.end(`${JSON.stringify({ ok: false, error: "request-not-found" })}\n`);
s.end(`${JSON.stringify(reply)}\n`);
});
});
await new Promise((r) => server.listen(path, r));
@@ -170,3 +170,22 @@ test("once a signalled runner git has closed, its group gets SIGKILL, so a proce
t.after(() => kill(pid));
assert.equal(await gone(pid, 1500), true, "the sleep that ignored SIGTERM is gone well inside the grace period");
});
// Row 137 (#1631): land_work sends the task and the decision and gives back
// the host's "started" answer; anything else the host says is a refusal or
// invalid-response.
test("land_work sends one land line and resolves the host's started answer", async (t) => {
const dir = scratch(t);
const started = { landing: "started", commit: "a".repeat(40), base: "b".repeat(40), reviewed: "c".repeat(40), patch_id: "d".repeat(40) };
const host = await fakeHost(t, dir, { ok: true, result: started });
assert.deepEqual(await landWork({ path: host.path, cap: "c".repeat(64), task_ref: "vikunja:1/2", decision: "decision-1" }), started);
assert.deepEqual(host.lines, [{ cap: "c".repeat(64), op: "land", task_ref: "vikunja:1/2", decision: "decision-1" }]);
const sub = (name) => (mkdirSync(join(dir, name)), join(dir, name));
const busy = await fakeHost(t, sub("busy"), { ok: false, error: "land-busy" });
await assert.rejects(landWork({ path: busy.path, cap: "c".repeat(64), task_ref: "vikunja:1/2", decision: "decision-1" }), (e) => e.code === "land-busy");
for (const [name, result] of [["landed", { ...started, landing: "landed" }], ["short", { ...started, commit: "a".repeat(7) }], ["no-base", { ...started, base: undefined }], ["null", null]]) {
const odd = await fakeHost(t, sub(name), { ok: true, result });
await assert.rejects(landWork({ path: odd.path, cap: "c".repeat(64), task_ref: "vikunja:1/2", decision: "decision-1" }), (e) => e.code === "invalid-response", name);
}
});
+18
View File
@@ -211,6 +211,24 @@ test("the review loop: push_working and request_review for the coder, fetch_revi
for (const tool of [...tools, ...rtools]) assert.equal(tool.parameters.additionalProperties, false);
});
// Row 137 (#1631): land_work only when the launch found a merge decision to
// land (landHeld), for any role and with no clone; the broker checks the rest.
test("land_work is there only with land, after push_working, with the task and the decision", (t) => {
const coder = resolvedFor(t, "coder");
assert.ok(!names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })).includes("land_work"));
const tools = typedTools({ resolved: coder, tracker: true, projects: ["stack"], land: true });
assert.deepEqual(names(tools).slice(3, 6), ["push_working", "land_work", "request_review"]);
const land = tools.find((x) => x.name === "land_work");
assert.deepEqual([land.verb, land.action], ["working.land", "git.merge.protected"]);
assert.deepEqual(land.parameters.required, ["task_ref", "decision"]);
assert.deepEqual(Object.keys(land.parameters.properties), ["task_ref", "decision"]);
assert.equal(land.parameters.additionalProperties, false);
assert.match(land.description, /answers "started" with the commit that will land\. The full test gate runs after that, and its outcome comes to you as a RESULT message/);
assert.match(land.description, /land-busy means another is running\.$/);
// A chief with no clone gets it too; land doesn't need a project.
assert.ok(names(typedTools({ resolved: resolvedFor(t, "chief"), land: true })).includes("land_work"));
});
test("callTool: one JSON line out, the result back, a refusal rejects", async (t) => {
const dir = scratch(t);
const sock = await fakeToolSocket(t, dir, (tool, args) => {
+73 -5
View File
@@ -196,7 +196,8 @@ and the file's header comment has the table.
`/usr`; node when it lives outside `/usr`; the broker socket, the
launch socket for the role whose launch tool calls it, and the host's
git socket (`bus-host/git.sock`) for a role that holds
git.push.working or review.verdict.
git.push.working or review.verdict, or a merge decision it could land
(the broker's `landHeld`, #1631).
- Read-write: the instance's workspace and the run directory. The run's
`bundle/` and `session.json` are bound read-only again on top.
- Private: `/tmp` is the run's `tmp/`, `HOME` the run's empty `home/`, and
@@ -374,8 +375,14 @@ A push runs under the repository's lock, in this order:
1. The bundle starts with the v2 header; v3 can carry a filter. Otherwise
`invalid-bundle`.
2. `main` is fetched again from the project's root, which the host trusts.
A root it can't read refuses `root-unreadable`.
2. The seed: the root's checked-out commit is fetched into `refs/heads/root`,
forced, since the root is the owner's and may move any way. `main` is
made from `root` once, and after that follows it only by fast-forward,
when `main` is an ancestor of `root`. A `main` that a landing moved past
the root stays where it is. `main` is never forced. A root the host
can't read, or one with no commit checked out, refuses
`root-unreadable`; a `main` that can't follow refuses
`working-repo-invalid`.
3. `git bundle verify`. A prerequisite the repository lacks refuses
`missing-prerequisite`; anything else wrong is `invalid-bundle`.
4. The bundle has the named branch, or `branch-not-in-bundle`.
@@ -392,7 +399,8 @@ A push runs under the repository's lock, in this order:
since the operator has to move that ref by hand.
The temp ref is deleted on every path, and so is the bundle file in
`bus-host/`. No other ref moves, and the root is only read.
`bus-host/`. Besides the work ref, only the seed moves refs (`root`, and
`main` by fast-forward), and the root is only read.
The repository's lock is a promise chain inside the host's process, so it
assumes one launcher per dataRoot. `startHost` takes `bus-host/host.lock`
@@ -413,7 +421,7 @@ its own isn't signalled. Since git has its own group, a Ctrl-C at the
terminal of a host started by hand doesn't reach it; a systemd stop still
does, since it kills the unit's cgroup.
For a review, under the same lock, the host fetches `main` again and
For a review, under the same lock, the host runs the seed again and
writes a bundle of the requested commit with one ref,
`refs/mosaic/review`. It leaves out the commits the reviewer says its
clones already have (at most 32, `HAVE_MAX`), but only those the working
@@ -427,6 +435,66 @@ it does for the clones: no system or global config, hooks at `/dev/null`,
`core.fsmonitor` off, only the `file` transport, no submodule recursion
and no auto-maintenance.
### Landing on main (#1631)
The bus host lands an approved task's work on the working repository's
`main` through the git socket's `land` op (`packages/cli/README.md`, "Git
socket"). `working.mjs` has the git steps, in three calls the host makes
around the gate. Each runs under the repository's lock.
`prepareLand` builds the squash and leaves it checked out in a worktree of
the bare repository, under the landing's own directory:
1. The repository exists (`ref-moved` if it was never pushed to), then the
seed runs.
2. The task's work ref is at the commit the verdict approved, or
`ref-moved`.
3. The merge-base of `main` and that commit is the base; none refuses
`base-conflict`. The reviewed diff, base to commit, goes to
`reviewed.patch` with `git diff --output`, so its bytes are git's own. An
empty diff refuses `base-conflict`.
4. The root-file rule (`src/landing.mjs`, `rootHits`) runs on the paths the
diff adds against `main`'s top level, or `root-file`.
5. A detached worktree at `main` takes the patch with `git apply --index`.
A patch that doesn't apply refuses `base-conflict`.
6. The applied diff's patch-id has to equal the reviewed one, or
`patch-drift`.
7. `write-tree`, then `commit-tree` with `main` as the one parent, as
"Mosaic bus host <bus-host@mosaic.invalid>". The worktree's HEAD moves
to the squash with `reset --soft`.
Any other git step that fails refuses `git-failed`. A refusal removes the
worktree. `prepareLand` resolves `{ worktree, old, commit, base, reviewed,
patch_id }`, where `commit` is the squash and `old` is `main`'s tip.
`finishLand` moves `main` from `old` to the squash with a compare-and-swap
`update-ref`. A `main` that moved in the meantime refuses `base-moved` and
nothing is written. Then the caller's `record()` runs; if it throws, `main`
goes back to `old`, and a `main` that can't go back refuses
`rollback-failed`, naming both commits.
`dropLand` removes the worktree: a `node_modules` symlink first, so nothing
follows it, then `git worktree remove --force` and `git worktree prune`.
`src/landing.mjs` holds what land-work (`scripts/land-work.sh`) and the
host share: `rootHits`, the gate's steps and its runner, `isMosaicStack`
and `gateEnv`. Its CLI (`gate`, `root-file`) is what land-work.sh calls.
- `gateSteps(worktree)`: every package's `node --test tests/*.test.mjs`
except `queue`'s, in name order, then every `scripts/test-*.sh`.
- `runGate({ worktree, logs, env, signal, report })` runs each step in its
own process group with its output in `<logs>/<step>.log`, and one summary
line per step (`<step> rc=N`, then the `ℹ pass` and `ℹ fail` counts) in
`<logs>/gate.log`. It stops at the first failing step. An abort sends
the running group SIGTERM, then SIGKILL 5 s later, and runs nothing
after it.
- `isMosaicStack(root)` is true when the root's HEAD descends from the new
foundation's root commit and the root has a `node_modules` directory.
- `gateEnv(env)` keeps `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `LANG`,
`LC_ALL`, `TZ`, `TERM`, `TMPDIR`, `XDG_RUNTIME_DIR` and `DOCKER_HOST`,
and drops the rest, so no credential reaches the host's gate. land-work.sh
runs its gate in the conductor's own environment.
## Exit codes
`launch` exits with the launch script's own code once the script runs.
+12 -3
View File
@@ -43,7 +43,7 @@
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { lstatSync, readdirSync, renameSync, rmSync } from "node:fs";
import { closeSync, lstatSync, openSync, readdirSync, renameSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
export const GIT_TIMEOUT_MS = 100_000;
@@ -100,9 +100,18 @@ export function gitEnv(env = process.env) {
// git's output. A git that ends on its own isn't signalled at all. One
// consequence: a Ctrl-C at the terminal of a host run by hand no longer
// reaches its git, though a systemd stop still does (it kills the cgroup).
export function runGit(argv, env, limits = GIT_LIMITS) {
// `stdin` names a file git reads as its input (row 137: patch-id); without
// it git gets none.
export function runGit(argv, env, limits = GIT_LIMITS, { stdin = null } = {}) {
return new Promise((resolve) => {
const child = spawn("git", argv, { env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
let input = "ignore";
try {
if (stdin !== null) input = openSync(stdin, "r");
} catch (e) {
return resolve({ ok: false, out: "", err: "", why: e.code ?? e.message });
}
const child = spawn("git", argv, { env, detached: true, stdio: [input, "pipe", "pipe"] });
if (typeof input === "number") closeSync(input);
let out = "";
let err = "";
let signalled = false;
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env node
// What a landing checks in the tree and runs before a branch moves (row 137,
// #1631): the root-file rule and the full gate. Two callers:
// scripts/land-work.sh, which lands on canonical refactor, and the bus
// host's git socket land op (working.mjs land), which lands on a working
// repository's main. Moved out of land-work.sh so there is one gate.
//
// node packages/seat/src/landing.mjs gate <worktree> <logs>
// Runs the gate in <worktree>. Each step's output goes to
// <logs>/<step>.log and one summary line per step to stderr and
// <logs>/gate.log. Exit 0 after GATE_PASSED; exit 2 with
// "<step><TAB><log>" on stdout for the first step that fails.
// node packages/seat/src/landing.mjs root-file <top-level names file> <added paths file>
// Both files hold NUL-separated paths. Prints the offending paths, one
// per line, and exits 2 if there are any.
import { spawn } from "node:child_process";
import { appendFileSync, existsSync, lstatSync, openSync, closeSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { gitEnv, runGit } from "./checkout.mjs";
// The new foundation's root commit, "chore: baseline container POC and atomic
// foundation plan" (2026-09-02, tag poc-container-hello-v0). A project whose
// checked-out history holds it is this repository.
export const MOSAIC_ROOT_COMMIT = "c2365ae5190aee751bfbd0a3d47105a706adb5e7";
// How long a gate step may wait after SIGTERM before SIGKILL.
const STEP_GRACE_MS = 5_000;
// Invariant 1: nothing new at the root. A top-level file, a top-level
// directory the base doesn't have, and the legacy src/ and archived v1/
// trees all count as the root. `added` are the paths the reviewed diff adds,
// `top` the base tree's top-level names.
export function rootHits(added, top) {
const known = new Set(top);
return added.filter((f) => {
const i = f.indexOf("/");
if (i < 0) return true;
const t = f.slice(0, i);
return !known.has(t) || t === "src" || t === "v1";
});
}
// await isMosaicStack(root, env)
// Whether the project root is a mosaic-stack checkout the gate can run in:
// its HEAD descends from MOSAIC_ROOT_COMMIT and it has node_modules to link.
// git runs with only PATH from the environment, as working.mjs runs it.
export async function isMosaicStack(root, env = process.env) {
try {
if (!statSync(join(root, "node_modules")).isDirectory()) return false;
} catch {
return false;
}
const r = await runGit(["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-C", root, "merge-base", "--is-ancestor", MOSAIC_ROOT_COMMIT, "HEAD"], gitEnv(env));
return r.ok;
}
// What the bus host's gate runs with: the host's environment less anything
// that could carry a credential or point a suite at the live stack. The
// suites make their own data roots and configs; they need a PATH, a HOME, a
// TMPDIR and Docker. land-work.sh runs its gate with the conductor's own
// environment and doesn't use this.
const GATE_KEEP = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "TZ", "TERM", "TMPDIR", "XDG_RUNTIME_DIR", "DOCKER_HOST"];
export function gateEnv(env = process.env) {
return Object.fromEntries(GATE_KEEP.filter((k) => typeof env[k] === "string").map((k) => [k, env[k]]));
}
// The gate's steps for the tree at `worktree`, in order: every package's
// node suite except queue's (scripts/test-queue.sh runs packages/queue/tests
// itself), then every scripts/test-*.sh.
export function gateSteps(worktree) {
const steps = [];
const dirs = (d) => (existsSync(d) ? readdirSync(d).sort() : []);
for (const p of dirs(join(worktree, "packages"))) {
if (p === "queue") continue;
try {
if (!lstatSync(join(worktree, "packages", p, "tests")).isDirectory()) continue;
} catch {
continue;
}
steps.push({ name: `pkg-${p}`, dir: join("packages", p), command: "node --test tests/*.test.mjs" });
}
for (const f of dirs(join(worktree, "scripts"))) {
const m = /^test-(.+)\.sh$/.exec(f);
if (!m) continue;
if (!lstatSync(join(worktree, "scripts", f)).isFile()) continue;
steps.push({ name: `script-${m[1]}`, dir: ".", command: `bash scripts/test-${m[1]}.sh` });
}
return steps;
}
// await runGate({ worktree, logs, env, signal, report })
// Runs each step in its own process group, its output in <logs>/<step>.log.
// `report(line)` gets each summary line, which also goes to <logs>/gate.log.
// Resolves { ok: true }, { ok: false, step, log } at the first failure, or
// { ok: false, aborted: true } when `signal` aborts; the running step's group
// gets SIGTERM, then SIGKILL STEP_GRACE_MS later.
export async function runGate({ worktree, logs, env = process.env, signal = null, report = () => {} }) {
const summary = (line) => {
appendFileSync(join(logs, "gate.log"), `${line}\n`);
report(line);
};
for (const step of gateSteps(worktree)) {
if (signal?.aborted) return { ok: false, aborted: true };
const log = join(logs, `${step.name}.log`);
const rc = await runStep(step, worktree, log, env, signal);
if (rc === null) return { ok: false, aborted: true };
// land-work's line: the name, rc=N, then each "ℹ pass N" and "ℹ fail N" line of the log.
const counts = readFileSync(log, "utf8")
.split("\n")
.filter((l) => /^ℹ (pass|fail) [0-9]+/.test(l));
summary(`${step.name} rc=${rc} ${counts.map((l) => `${l} `).join("")}`);
if (rc !== 0) return { ok: false, step: step.name, log };
}
summary("GATE_PASSED");
return { ok: true };
}
// The step's exit code, or null when the signal stopped it.
function runStep(step, worktree, log, env, signal) {
return new Promise((resolve) => {
const fd = openSync(log, "w");
const child = spawn("bash", ["-c", `set -o pipefail; ${step.command}`], {
cwd: join(worktree, step.dir),
env,
detached: true,
stdio: ["ignore", fd, fd],
});
closeSync(fd);
let kill = null;
const group = (sig) => {
try {
process.kill(-child.pid, sig);
} catch {}
};
const abort = () => {
group("SIGTERM");
kill = setTimeout(() => group("SIGKILL"), STEP_GRACE_MS);
};
signal?.addEventListener("abort", abort, { once: true });
const done = (rc) => {
signal?.removeEventListener("abort", abort);
clearTimeout(kill);
// Anything the step left in its group goes with it.
group("SIGKILL");
resolve(signal?.aborted ? null : rc);
};
child.on("error", () => done(127));
child.on("close", (code, sig) => done(code ?? (sig ? 128 : 1)));
});
}
async function main([verb, ...args]) {
if (verb === "gate" && args.length === 2) {
const [worktree, logs] = args;
const r = await runGate({ worktree, logs, report: (line) => process.stderr.write(`${line}\n`) });
if (r.ok) return 0;
process.stdout.write(`${r.step}\t${r.log}\n`);
return 2;
}
if (verb === "root-file" && args.length === 2) {
const list = (f) => readFileSync(f, "utf8").split("\0").filter(Boolean);
const hits = rootHits(list(args[1]), list(args[0]));
for (const h of hits) process.stdout.write(`${h}\n`);
return hits.length ? 2 : 0;
}
process.stderr.write("usage: landing.mjs gate <worktree> <logs> | root-file <top-file> <added-file>\n");
return 4;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exitCode = await main(process.argv.slice(2));
+2 -1
View File
@@ -58,6 +58,7 @@ export const RUNNER_FILES = Object.freeze([
"packages/harness/src/pi-extension.mjs",
"packages/bus/src/client.mjs",
"packages/bus/src/broker.mjs",
"packages/bus/src/landing.mjs",
"packages/business/src/vocabulary.mjs",
"packages/tasks/src/requirements.mjs",
].map((p) => join(REPO, p)));
@@ -108,7 +109,7 @@ export function binds({ runDir, workspace, adapter, harness, brokerSocket, launc
}
out.push({ kind: "ro", src: brokerSocket, why: "the broker socket the runner claims and calls through" });
if (launchSocket) out.push({ kind: "ro", src: launchSocket, why: "the launch socket, for the role whose launch tool calls it" });
if (gitSocket) out.push({ kind: "ro", src: gitSocket, why: "the host's git socket, for push_working and fetch_review" });
if (gitSocket) out.push({ kind: "ro", src: gitSocket, why: "the host's git socket, for push_working and fetch_review, and land_work" });
for (const f of readOnly) out.push({ kind: "ro", src: f, why: "a test fixture" });
out.push(
{ kind: "rw", src: workspace, why: "the instance's workspace, where the harness works" },
+163 -9
View File
@@ -19,7 +19,8 @@
//
// push, under the repository's lock:
// 1. the bundle file starts with the v2 header (v3 can carry a filter)
// 2. main is fetched again from the project's root, which the host trusts
// 2. the project's root is fetched again as refs/heads/root, which the
// host trusts, and main follows it if it can fast-forward (seed)
// 3. `bundle verify`: a prerequisite the repository lacks refuses
// missing-prerequisite
// 4. the bundle has the named branch (branch-not-in-bundle)
@@ -44,12 +45,27 @@
// dataRoot: it takes bus-host/host.lock with a hard link, which fails if
// the file exists.
//
// review, under the same lock: main again from the root, then a bundle of
// the requested commit that leaves out the commits the reviewer said it has
// review, under the same lock: the seed again, then a bundle of the
// requested commit that leaves out the commits the reviewer said it has
// (those the repository knows), written to the file the caller names.
//
// land (row 137, #1631) moves main to one squashed commit of a reviewed work
// ref. The git socket (packages/cli/src/git-socket.mjs) runs it in two steps
// around the gate, which runs outside the lock:
// prepareLand, under the lock: the seed; the work ref is at the reviewed
// commit C (ref-moved); MB = merge-base(main, C) (base-conflict); the
// reviewed patch MB..C, which must not be empty (base-conflict), and its
// patch-id; nothing new at the root (root-file); a detached worktree on
// main where the patch applies (base-conflict) to the same patch-id
// (patch-drift); then commit-tree, so the commit is fixed before the
// gate starts. No ref moves.
// finishLand, under the lock: main moves from the old tip to the commit by
// compare and swap (base-moved), then record() runs; when it throws, main
// goes back, and a main that can't go back refuses rollback-failed.
// dropLand removes the worktree; the caller runs it on every path.
import { randomBytes } from "node:crypto";
import { closeSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync } from "node:fs";
import { closeSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { gitEnv, GIT_LIMITS, runGit } from "./checkout.mjs";
@@ -85,7 +101,7 @@ export const FSCK = ["-c", "transfer.fsckObjects=true"];
// `limits` is { timeoutMs, graceMs }; the tests shorten them. runGit
// (checkout.mjs) runs git in its own process group and signals the group.
const git = (args, env, { pre = [], limits = GIT_LIMITS } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits);
const git = (args, env, { pre = [], limits = GIT_LIMITS, stdin = null } = {}) => runGit([...SAFE, ...pre, ...args], gitEnv(env), limits, { stdin });
// git with this call's env and limits, for the helpers below.
const bound = (env, limits) => (args, opts = {}) => git(args, env, { ...opts, limits });
@@ -141,11 +157,23 @@ async function ensure(repo, g) {
}
}
// main from what the root has checked out. The root is the owner's; it isn't
// a session's.
// What the root has checked out, as refs/heads/root, forced: the root is the
// owner's, not a session's, and may move any way. main starts at root and
// follows it only by fast-forward, since a landing (row 137) moves main past
// root and a reseed must not throw the landing away. main is never forced.
async function seed(repo, root, g) {
const r = await g([`--git-dir=${repo}`, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", root, "+HEAD:refs/heads/main"]);
if (!r.ok) refuse("root-unreadable", `main couldn't be fetched from ${root}: ${r.why}`);
const at = `--git-dir=${repo}`;
const r = await g([at, "fetch", "-q", "--no-tags", "--no-write-fetch-head", "--no-recurse-submodules", "--no-auto-maintenance", root, "+HEAD:refs/heads/root"]);
if (!r.ok) refuse("root-unreadable", `the root couldn't be fetched from ${root}: ${r.why}`);
const top = await tip(repo, "refs/heads/root", g);
if (!top) refuse("root-unreadable", `${root} has no commit checked out`);
const main = await tip(repo, "refs/heads/main", g);
if (main === top) return;
if (main !== null && !(await g([at, "merge-base", "--is-ancestor", main, top])).ok) return;
// An empty old value creates main only if it doesn't exist; otherwise the
// swap moves it only from where it was read.
const moved = await g([at, "update-ref", "refs/heads/main", top, main ?? ""]);
if (!moved.ok) refuse("working-repo-invalid", `main couldn't follow root to ${top}: ${moved.why}`);
}
function header(file) {
@@ -248,3 +276,129 @@ export function review({ dataRoot, business, project, root, taskRef, commit, hav
return { ref: workRef(taskRef), commit };
});
}
// The bus host's name on a landed commit, as author and committer.
export const LAND_IDENTITY = ["-c", "user.name=Mosaic bus host", "-c", "[email protected]"];
// A diff in one fixed format, as land-work's pdiff makes it. --output writes
// git's bytes as they are, so a file that isn't UTF-8 keeps its patch-id.
const PDIFF = ["-c", "diff.noprefix=false", "-c", "diff.mnemonicPrefix=false"];
const DIFF = ["diff", "--no-ext-diff", "--no-textconv", "--no-color", "--binary", "--no-renames"];
// The patch-id of a patch file, or null when it has none.
async function patchId(repo, file, g) {
const r = await g([`--git-dir=${repo}`, "patch-id", "--stable"], { stdin: file });
const id = r.ok ? r.out.split(" ")[0] : "";
return OID.test(id) ? id : null;
}
// await prepareLand({ dataRoot, business, project, root, taskRef, commit, dir, name, rootHits, message, env, limits })
// `commit` is the commit the verdict approved. `dir` is the landing's own
// directory, which the caller made and owns; the worktree goes at
// <dir>/<name>, so `name` is what Compose names a project after, and the
// patches and message go beside it. `rootHits(added, top)` is the root-file
// rule (packages/seat/src/landing.mjs). `message({ old, base, patch_id })`
// returns the commit message. Resolves { worktree, old, commit, base,
// reviewed, patch_id }: the worktree is checked out at `commit`, the squash,
// whose one parent is `old`, main's tip. Rejects with WorkingError and leaves
// no worktree behind.
export function prepareLand({ dataRoot, business, project, root, taskRef, commit, dir, name, rootHits, message, env = process.env, limits = GIT_LIMITS }) {
if (!PROJECT.test(project ?? "") || !OID.test(commit ?? "") || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(name ?? "")) refuse("invalid-request");
const work = workRef(taskRef);
const repo = workingRepo(dataRoot, business, project);
const g = bound(env, limits);
const worktree = join(dir, name);
return locked(repo, async () => {
try {
if (!lstatSync(repo).isDirectory()) refuse("working-repo-invalid");
} catch (e) {
if (e instanceof WorkingError) throw e;
refuse("ref-moved", `${work} was never pushed: the working repository doesn't exist`);
}
await seed(repo, root, g);
const at = [`--git-dir=${repo}`];
const now = await tip(repo, `refs/heads/${work}`, g);
if (now !== commit) refuse("ref-moved", `${work} is at ${now ?? "nothing"}, the verdict approved ${commit}`);
const old = await tip(repo, "refs/heads/main", g);
if (!old) refuse("working-repo-invalid", "main isn't a commit");
const mb = await g([...at, "merge-base", old, commit]);
if (!mb.ok) refuse("base-conflict", `${commit} shares no history with main`);
const base = mb.out.trim();
const reviewed = join(dir, "reviewed.patch");
if (!(await g([...PDIFF, ...at, ...DIFF, `--output=${reviewed}`, base, commit])).ok) refuse("git-failed", "the reviewed diff couldn't be made");
if (!lstatSync(reviewed).size) refuse("base-conflict", `the reviewed diff ${base}..${commit} is empty`);
const patch_id = await patchId(repo, reviewed, g);
if (!patch_id) refuse("git-failed", "the reviewed diff has no patch-id");
const added = await g([...at, "diff", "-z", "--no-renames", "--name-only", "--diff-filter=A", base, commit]);
const top = await g([...at, "ls-tree", "-z", "--name-only", old]);
if (!added.ok || !top.ok) refuse("git-failed", "the root-file check couldn't read the trees");
const hits = rootHits(added.out.split("\0").filter(Boolean), top.out.split("\0").filter(Boolean));
if (hits.length) refuse("root-file", `the reviewed diff adds ${hits.join(" ")}`);
const opened = await g([...at, "worktree", "add", "-q", "--detach", worktree, old]);
if (!opened.ok) refuse("git-failed", `the worktree couldn't be added: ${opened.why}`);
try {
const wt = ["-C", worktree];
const applied = await g([...wt, "apply", "--index", reviewed]);
if (!applied.ok) refuse("base-conflict", applied.why);
const check = join(dir, "applied.patch");
if (!(await g([...PDIFF, ...wt, ...DIFF, "--cached", `--output=${check}`, old])).ok) refuse("git-failed", "the applied diff couldn't be made");
const appliedId = await patchId(repo, check, g);
if (appliedId !== patch_id) refuse("patch-drift", `applied ${appliedId ?? "none"}, reviewed ${patch_id}`);
const tree = await g([...wt, "write-tree"]);
if (!tree.ok) refuse("git-failed", "write-tree failed");
const file = join(dir, "message");
writeFileSync(file, message({ old, base, patch_id }), { mode: 0o600 });
const made = await g([...wt, "commit-tree", tree.out.trim(), "-p", old, "-F", file], { pre: LAND_IDENTITY });
const squash = made.out.trim();
if (!made.ok || !OID.test(squash)) refuse("git-failed", `commit-tree failed: ${made.why}`);
// The index and files are the squash's already; HEAD follows them.
if (!(await g([...wt, "reset", "-q", "--soft", squash])).ok) refuse("git-failed", "the worktree couldn't move to the squash");
return { worktree, old, commit: squash, base, reviewed: commit, patch_id };
} catch (e) {
await removeWorktree(repo, worktree, g);
throw e;
}
});
}
// await finishLand({ dataRoot, business, project, land, record, env, limits })
// `land` is what prepareLand resolved. Under the lock, main moves from
// land.old to land.commit, then record() runs. Resolves { previous, commit }.
export function finishLand({ dataRoot, business, project, land, record = async () => {}, env = process.env, limits = GIT_LIMITS }) {
const repo = workingRepo(dataRoot, business, project);
const g = bound(env, limits);
const main = "refs/heads/main";
return locked(repo, async () => {
const at = [`--git-dir=${repo}`];
const moved = await g([...at, "update-ref", main, land.commit, land.old]);
if (!moved.ok) refuse("base-moved", `main is no longer at ${land.old}; nothing was written`);
try {
await record({ previous: land.old, commit: land.commit });
} catch (e) {
const undone = await g([...at, "update-ref", main, land.old, land.commit]);
if (!undone.ok) refuse("rollback-failed", `main still names ${land.commit}, which wasn't recorded (${e.code ?? e.message}); moving it back to ${land.old} failed: ${undone.why}`);
throw e;
}
return { previous: land.old, commit: land.commit };
});
}
// await dropLand({ dataRoot, business, project, worktree, env, limits })
// Removes the landing's worktree and its record in the repository. A
// node_modules link the gate added goes first, so nothing follows it.
export function dropLand({ dataRoot, business, project, worktree, env = process.env, limits = GIT_LIMITS }) {
const repo = workingRepo(dataRoot, business, project);
return locked(repo, () => removeWorktree(repo, worktree, bound(env, limits)));
}
async function removeWorktree(repo, worktree, g) {
const link = join(worktree, "node_modules");
try {
if (lstatSync(link).isSymbolicLink()) rmSync(link);
} catch {}
const r = await g([`--git-dir=${repo}`, "worktree", "remove", "--force", worktree]);
if (!r.ok) rmSync(worktree, { recursive: true, force: true });
await g([`--git-dir=${repo}`, "worktree", "prune"]);
}
+207
View File
@@ -0,0 +1,207 @@
// What a landing checks and runs (src/landing.mjs, row 137, #1631): the
// root-file rule, the mosaic-stack check, the gate's environment, its steps
// and its runs, and the CLI land-work.sh calls. The gates here run in fake
// trees with small packages and scripts; none runs this repository's suites.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { gitEnv } from "../src/checkout.mjs";
import { gateEnv, gateSteps, isMosaicStack, MOSAIC_ROOT_COMMIT, rootHits, runGate } from "../src/landing.mjs";
const REPO = fileURLToPath(new URL("../../..", import.meta.url));
const CLI = join(REPO, "packages", "seat", "src", "landing.mjs");
function tmp(t) {
const dir = mkdtempSync(join(tmpdir(), "mosaic-landing-"));
t.after(() => rmSync(dir, { recursive: true, force: true }));
return dir;
}
function git(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=t", "-c", "[email protected]", ...args], { cwd, encoding: "utf8", env: gitEnv() });
assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`);
return r.stdout.trim();
}
function write(path, text) {
mkdirSync(join(path, ".."), { recursive: true });
writeFileSync(path, text);
}
// A test file that passes, or fails when `pass` is false.
const nodeTest = (pass) => `import { test } from "node:test";\nimport assert from "node:assert/strict";\ntest("t", () => assert.ok(${pass}));\n`;
// A tree with: packages a and c with tests, b without, queue (skipped);
// scripts test-one.sh and test-two.sh, and other.sh, which isn't a step.
function tree(t, { two = "echo two\n" } = {}) {
const w = tmp(t);
write(join(w, "packages", "c", "tests", "c.test.mjs"), nodeTest(true));
write(join(w, "packages", "a", "tests", "a.test.mjs"), nodeTest(true));
write(join(w, "packages", "b", "src", "b.mjs"), "export {};\n");
write(join(w, "packages", "queue", "tests", "q.test.mjs"), nodeTest(false));
write(join(w, "scripts", "test-two.sh"), two);
write(join(w, "scripts", "test-one.sh"), 'printf "%s\\n" "${GATE_SEEN-unset}" > "$PWD/one.env"\n');
write(join(w, "scripts", "other.sh"), "exit 9\n");
const logs = join(w, "logs");
mkdirSync(logs);
return { w, logs };
}
// The test runner's own context would turn an inner `node --test` into its child.
const env = (extra = {}) => ({ ...gateEnv(), ...extra });
test("rootHits: a top-level file, a new top-level directory, src/ and v1/ are the root; paths under known directories aren't", () => {
const top = ["docs", "packages", "scripts", "src", "v1", "README.md"];
assert.deepEqual(rootHits(["packages/a/x.mjs", "docs/y.md", "NEW.md", "newdir/z", "src/a", "v1/b", "README.md"], top), ["NEW.md", "newdir/z", "src/a", "v1/b", "README.md"]);
assert.deepEqual(rootHits([], top), []);
});
test("gateEnv keeps the named variables that are strings and drops everything else", () => {
const kept = gateEnv({ PATH: "/bin", HOME: "/h", TMPDIR: "/t", DOCKER_HOST: "unix:///d", MOSAIC_CONFIG: "/c", ANTHROPIC_API_KEY: "k", NODE_TEST_CONTEXT: "child-v8", TZ: undefined });
assert.deepEqual(kept, { PATH: "/bin", HOME: "/h", TMPDIR: "/t", DOCKER_HOST: "unix:///d" });
});
test("isMosaicStack: a checkout whose HEAD descends from the root commit and has node_modules; nothing else", async (t) => {
const objects = join(git(REPO, "rev-parse", "--path-format=absolute", "--git-common-dir"), "objects");
const dir = tmp(t);
const ours = join(dir, "ours");
git(dir, "init", "-q", ours);
// Borrow this repository's objects rather than copy its history.
writeFileSync(join(ours, ".git", "objects", "info", "alternates"), `${objects}\n`);
const rootTree = git(ours, "rev-parse", `${MOSAIC_ROOT_COMMIT}^{tree}`);
const child = git(ours, "commit-tree", rootTree, "-p", MOSAIC_ROOT_COMMIT, "-m", "child");
git(ours, "update-ref", "refs/heads/main", child);
git(ours, "symbolic-ref", "HEAD", "refs/heads/main");
assert.equal(await isMosaicStack(ours), false, "no node_modules");
writeFileSync(join(ours, "node_modules"), "");
assert.equal(await isMosaicStack(ours), false, "node_modules is a file");
rmSync(join(ours, "node_modules"));
mkdirSync(join(ours, "node_modules"));
assert.equal(await isMosaicStack(ours), true);
const other = join(dir, "other");
git(dir, "init", "-q", other);
writeFileSync(join(other, "README"), "x\n");
git(other, "add", "README");
git(other, "commit", "-q", "-m", "x");
mkdirSync(join(other, "node_modules"));
assert.equal(await isMosaicStack(other), false, "an unrelated history");
const plain = join(dir, "plain");
mkdirSync(join(plain, "node_modules"), { recursive: true });
assert.equal(await isMosaicStack(plain), false, "not a repository");
assert.equal(await isMosaicStack(join(dir, "missing")), false);
});
test("gateSteps: each package with tests but queue in name order, then each scripts/test-*.sh", (t) => {
const { w } = tree(t);
assert.deepEqual(gateSteps(w), [
{ name: "pkg-a", dir: join("packages", "a"), command: "node --test tests/*.test.mjs" },
{ name: "pkg-c", dir: join("packages", "c"), command: "node --test tests/*.test.mjs" },
{ name: "script-one", dir: ".", command: "bash scripts/test-one.sh" },
{ name: "script-two", dir: ".", command: "bash scripts/test-two.sh" },
]);
assert.deepEqual(gateSteps(tmp(t)), []);
});
test("runGate: every step passes, each with its own log and a summary line, under the environment it was given", async (t) => {
const { w, logs } = tree(t);
const lines = [];
const r = await runGate({ worktree: w, logs, env: env({ GATE_SEEN: "given" }), report: (l) => lines.push(l) });
assert.deepEqual(r, { ok: true });
assert.deepEqual(lines.map((l) => l.split(" ")[0]), ["pkg-a", "pkg-c", "script-one", "script-two", "GATE_PASSED"]);
assert.match(lines[0], /^pkg-a rc=0 ℹ pass 1 ℹ fail 0 $/);
assert.equal(lines[2], "script-one rc=0 ");
assert.equal(readFileSync(join(logs, "gate.log"), "utf8"), lines.map((l) => `${l}\n`).join(""));
for (const s of ["pkg-a", "pkg-c", "script-one", "script-two"]) assert.ok(existsSync(join(logs, `${s}.log`)), s);
assert.equal(readFileSync(join(logs, "script-two.log"), "utf8"), "two\n");
assert.equal(readFileSync(join(w, "one.env"), "utf8"), "given\n");
});
test("runGate: the first failing step stops the gate and names its log", async (t) => {
const { w, logs } = tree(t, { two: "echo broke\nexit 3\n" });
// test-z.sh sorts after test-two.sh, so only a stop keeps it from running.
write(join(w, "scripts", "test-z.sh"), "touch ran-z\n");
const r = await runGate({ worktree: w, logs, env: env() });
assert.deepEqual(r, { ok: false, step: "script-two", log: join(logs, "script-two.log") });
assert.match(readFileSync(join(logs, "gate.log"), "utf8"), /script-two rc=3 \n$/);
assert.equal(existsSync(join(w, "ran-z")), false);
// A failing node suite fails its package's step.
const f = tree(t);
write(join(f.w, "packages", "a", "tests", "a.test.mjs"), nodeTest(false));
const p = await runGate({ worktree: f.w, logs: f.logs, env: env() });
assert.deepEqual(p, { ok: false, step: "pkg-a", log: join(f.logs, "pkg-a.log") });
assert.match(readFileSync(join(f.logs, "gate.log"), "utf8"), /^pkg-a rc=1 ℹ pass 0 ℹ fail 1 \n$/);
});
test("runGate: an abort stops the running step and everything in its group, and runs nothing after it", async (t) => {
const { w, logs } = tree(t, { two: 'sleep 300 &\necho $! > "$PWD/sleep.pid"\nwait\n' });
write(join(w, "scripts", "test-z.sh"), "touch ran-z\n");
const ac = new AbortController();
const pid = join(w, "sleep.pid");
const gate = runGate({ worktree: w, logs, env: env(), signal: ac.signal });
while (!existsSync(pid) || !readFileSync(pid, "utf8").trim()) await new Promise((r) => setTimeout(r, 20));
ac.abort();
assert.deepEqual(await gate, { ok: false, aborted: true });
// The orphaned sleep may stay a zombie for a moment before init reaps it.
const sleeper = Number(readFileSync(pid, "utf8"));
const gone = () => {
try {
return readFileSync(`/proc/${sleeper}/stat`, "utf8").split(") ")[1][0] === "Z";
} catch {
return true;
}
};
const until = Date.now() + 5000;
while (!gone() && Date.now() < until) await new Promise((r) => setTimeout(r, 20));
assert.ok(gone(), `sleep ${sleeper} still runs`);
assert.equal(existsSync(join(w, "ran-z")), false);
assert.doesNotMatch(readFileSync(join(logs, "gate.log"), "utf8"), /script-two|GATE_PASSED/);
// A signal that has already aborted runs nothing.
const f = tree(t);
const done = new AbortController();
done.abort();
assert.deepEqual(await runGate({ worktree: f.w, logs: f.logs, env: env(), signal: done.signal }), { ok: false, aborted: true });
assert.equal(existsSync(join(f.logs, "gate.log")), false);
});
// The CLI land-work.sh calls.
function cli(...args) {
return spawnSync(process.execPath, [CLI, ...args], { encoding: "utf8", env: env() });
}
test("the CLI: root-file prints the hits and exits 2, or exits 0; gate exits 0, or 2 with the step and its log; anything else is usage", (t) => {
const dir = tmp(t);
writeFileSync(join(dir, "top"), ["docs", "packages", ""].join("\0"));
writeFileSync(join(dir, "bad"), ["docs/a.md", "NEW.md", "src/x", ""].join("\0"));
writeFileSync(join(dir, "good"), ["docs/a.md", "packages/p/b.mjs"].join("\0"));
let r = cli("root-file", join(dir, "top"), join(dir, "bad"));
assert.equal(r.status, 2);
assert.equal(r.stdout, "NEW.md\nsrc/x\n");
r = cli("root-file", join(dir, "top"), join(dir, "good"));
assert.equal(r.status, 0);
assert.equal(r.stdout, "");
const pass = tree(t);
r = cli("gate", pass.w, pass.logs);
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, "");
assert.match(r.stderr, /GATE_PASSED\n$/);
const fail = tree(t, { two: "exit 5\n" });
r = cli("gate", fail.w, fail.logs);
assert.equal(r.status, 2);
assert.equal(r.stdout, `script-two\t${join(fail.logs, "script-two.log")}\n`);
for (const args of [[], ["gate", pass.w], ["root-file", "x"], ["land"]]) {
r = cli(...args);
assert.equal(r.status, 4, args.join(" "));
assert.match(r.stderr, /^usage: landing\.mjs /);
}
});
+207 -13
View File
@@ -12,7 +12,8 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync,
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gitEnv } from "../src/checkout.mjs";
import { push, review, validBranch, workingRepo, workRef, WorkingError } from "../src/working.mjs";
import { dropLand, finishLand, prepareLand, push, review, validBranch, workingRepo, workRef, WorkingError } from "../src/working.mjs";
import { rootHits } from "../src/landing.mjs";
const TASK = "vikunja:1/2";
const REF = "refs/heads/work/vikunja-1-2";
@@ -138,7 +139,7 @@ test("a push lands as the task's work ref, a fast-forward lands after it, and ma
const r1 = await lands(w, () => w.go(w.bundle(), { record: async (r) => seen.push(r) }));
assert.deepEqual(r1, { ref: "work/vikunja-1-2", commit: c1, previous: null });
assert.deepEqual(seen, [r1]);
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c1}`);
assert.equal(w.refs(), `refs/heads/main ${w.first}\nrefs/heads/root ${w.first}\n${REF} ${c1}`);
// The runner's clone remembers the push, so the next bundle carries only c2.
git(w.clone, "update-ref", "refs/remotes/working/work/vikunja-1-2", c1);
@@ -147,7 +148,7 @@ test("a push lands as the task's work ref, a fast-forward lands after it, and ma
assert.match(git(w.dir, "bundle", "list-heads", file), new RegExp(c2));
const r2 = await lands(w, () => w.go(file));
assert.deepEqual(r2, { ref: "work/vikunja-1-2", commit: c2, previous: c1 });
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c2}`);
assert.equal(w.refs(), `refs/heads/main ${w.first}\nrefs/heads/root ${w.first}\n${REF} ${c2}`);
});
test("a rewritten branch refuses non-fast-forward and the work ref stays", async (t) => {
@@ -166,7 +167,7 @@ test("a branch with no history in common with main refuses unrelated-history", a
git(w.clone, "rm", "-q", "-rf", ".");
commit(w.clone, "alone");
await refuses(w, () => w.go(w.bundle()), "unrelated-history");
assert.equal(w.refs(), `refs/heads/main ${w.first}`);
assert.equal(w.refs(), `refs/heads/main ${w.first}\nrefs/heads/root ${w.first}`);
});
test("a bundle that needs commits the working repository lacks refuses missing-prerequisite", async (t) => {
@@ -179,7 +180,7 @@ test("a bundle that needs commits the working repository lacks refuses missing-p
await refuses(w, () => w.go(file), "missing-prerequisite");
});
test("main is fetched from the root again before each push, so a clone of a newer root pushes", async (t) => {
test("the root is fetched again before each push and main follows it, so a clone of a newer root pushes", async (t) => {
const w = world(t);
git(w.clone, "checkout", "-q", "-b", "feat");
commit(w.clone, "two");
@@ -246,7 +247,7 @@ test("a branch named main, or named for another task, moves only this task's wor
const r = await lands(w, () => push({ dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef: TASK, branch, bundle: w.bundle(branch) }));
assert.equal(r.ref, "work/vikunja-1-2");
}
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c}`);
assert.equal(w.refs(), `refs/heads/main ${w.first}\nrefs/heads/root ${w.first}\n${REF} ${c}`);
});
test("a record that throws moves the work ref back, or removes a new one", async (t) => {
@@ -257,11 +258,11 @@ test("a record that throws moves the work ref back, or removes a new one", async
throw new Error("not recorded");
};
await assert.rejects(w.go(w.bundle(), { record: boom }), /not recorded/);
assert.equal(w.refs(), `refs/heads/main ${w.first}`);
assert.equal(w.refs(), `refs/heads/main ${w.first}\nrefs/heads/root ${w.first}`);
const c1 = (await w.go(w.bundle())).commit;
commit(w.clone, "three");
await assert.rejects(w.go(w.bundle(), { record: boom }), /not recorded/);
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c1}`);
assert.equal(w.refs(), `refs/heads/main ${w.first}\nrefs/heads/root ${w.first}\n${REF} ${c1}`);
});
test("a work ref that can't go back refuses rollback-failed, naming the ref and the commit", async (t) => {
@@ -277,13 +278,13 @@ test("a work ref that can't go back refuses rollback-failed, naming the ref and
const message = (c) => (e) => e instanceof WorkingError && e.code === "rollback-failed" && e.message.includes(`${REF} still names ${c}`) && /not recorded/.test(e.message);
// A new ref that can't be removed.
await assert.rejects(w.go(w.bundle(), { record: stuck }), message(c1));
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c1}`);
assert.equal(w.refs(), `refs/heads/main ${w.first}\nrefs/heads/root ${w.first}\n${REF} ${c1}`);
rmSync(lock);
// A ref that can't go back to the one before.
git(w.clone, "update-ref", "refs/remotes/working/work/vikunja-1-2", c1);
const c2 = commit(w.clone, "three");
await assert.rejects(w.go(w.bundle(), { record: stuck }), message(c2));
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c2}`);
assert.equal(w.refs(), `refs/heads/main ${w.first}\nrefs/heads/root ${w.first}\n${REF} ${c2}`);
rmSync(lock);
assert.deepEqual(locks(w.repo), []);
});
@@ -324,7 +325,7 @@ exec "$REAL" "\${args[@]}"`,
const limits = { timeoutMs: 2000, graceMs: 5000 };
await assert.rejects(w.go(w.bundle(), { env, limits }), (e) => e instanceof WorkingError && e.code === "push-failed" && /killed by SIGTERM/.test(e.message));
assert.deepEqual(locks(w.repo), []);
assert.equal(w.refs(), `refs/heads/main ${w.first}\n${REF} ${c1}`);
assert.equal(w.refs(), `refs/heads/main ${w.first}\nrefs/heads/root ${w.first}\n${REF} ${c1}`);
const c3 = commit(w.clone, "four");
const r = await lands(w, () => w.go(w.bundle()));
assert.deepEqual(r, { ref: "work/vikunja-1-2", commit: c3, previous: c1 });
@@ -334,10 +335,10 @@ test("a git that ignores SIGTERM gets SIGKILL once the grace period is over", {
const w = world(t);
git(w.clone, "checkout", "-q", "-b", "feat");
commit(w.clone, "two");
// The fetch of main from the root never ends and ignores SIGTERM.
// The fetch of the root never ends and ignores SIGTERM.
const env = wrapGit(
w,
`for a in "$@"; do [ "$a" = +HEAD:refs/heads/main ] && { trap '' TERM; while :; do sleep 0.05; done; }; done
`for a in "$@"; do [ "$a" = +HEAD:refs/heads/root ] && { trap '' TERM; while :; do sleep 0.05; done; }; done
exec "$REAL" "$@"`,
);
await assert.rejects(w.go(w.bundle(), { env, limits: { timeoutMs: 500, graceMs: 500 } }), (e) => e instanceof WorkingError && e.code === "root-unreadable" && /killed by SIGKILL/.test(e.message));
@@ -385,3 +386,196 @@ test("the runner's git tools hold the host's limits and branch rule", async () =
assert.equal(tools.HAVE_MAX, working.HAVE_MAX);
for (const b of ["feat", "a/b", "-x", "a..b", "a.lock", "a/", "x".repeat(200), "x".repeat(201)]) assert.equal(tools.validBranch(b), working.validBranch(b), b);
});
// Row 137 (#1631): the seed keeps a landed main, and a landing squashes the
// reviewed diff onto main.
const main = (w) => git(w.dir, `--git-dir=${w.repo}`, "rev-parse", "refs/heads/main");
const message = ({ old, base, patch_id }) => `Land it\n\nBase: main ${old}\nReviewed-base: ${base}\nPatch-id: ${patch_id}\n`;
// The repository's worktrees other than itself.
const trees = (w) =>
git(w.dir, `--git-dir=${w.repo}`, "worktree", "list", "--porcelain")
.split("\n")
.filter((l) => l.startsWith("worktree ") && l !== `worktree ${w.repo}`);
// A commit on `branch` of the clone, from `from`, that writes `file`.
function change(w, branch, from, file, text) {
git(w.clone, "checkout", "-q", "-B", branch, from);
writeFileSync(join(w.clone, file), `${text}\n`);
git(w.clone, "add", file);
git(w.clone, "commit", "-q", "-m", text);
return git(w.clone, "rev-parse", "HEAD");
}
const pushAs = (w, taskRef, branch) => push({ dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef, branch, bundle: w.bundle(branch) });
function landing(w, extra = {}) {
const dir = mkdtempSync(join(w.dir, "land-"));
const args = { dataRoot: w.dataRoot, business: "acme", project: "stack", root: w.root, taskRef: TASK, dir, name: "land-0a1b2c", rootHits, message, ...extra };
return { dir, prepare: () => prepareLand(args), finish: (land, record) => finishLand({ ...args, land, record }), drop: (worktree) => dropLand({ ...args, worktree }) };
}
// The task's change on feat, pushed and landed.
async function landed(w) {
const c = change(w, "feat", w.first, "README", "two");
await w.go(w.bundle());
const l = landing(w, { commit: c });
const land = await l.prepare();
await l.finish(land);
await l.drop(land.worktree);
return { c, land };
}
test("a reseed moves root but leaves a landed main, and main forwards once root contains it", async (t) => {
const w = world(t);
const { land } = await landed(w);
assert.equal(main(w), land.commit);
// The root moves on without the landing. main isn't behind root, so the seed leaves it.
writeFileSync(join(w.root, "NOTES"), "owner\n");
git(w.root, "add", "NOTES");
git(w.root, "commit", "-q", "-m", "owner");
const newer = git(w.root, "rev-parse", "HEAD");
const c2 = commit(w.clone, "three");
await w.go(w.bundle());
assert.equal(w.refs(), `refs/heads/main ${land.commit}\nrefs/heads/root ${newer}\n${REF} ${c2}`);
// The owner merges main into the root; the next seed forwards main to it.
git(w.root, "fetch", "-q", w.repo, "refs/heads/main");
git(w.root, "merge", "-q", "--no-edit", "FETCH_HEAD");
const merged = git(w.root, "rev-parse", "HEAD");
commit(w.clone, "four");
await w.go(w.bundle());
assert.equal(main(w), merged);
});
test("after a landing, a push built on the root still lands and main stays where the landing put it", async (t) => {
const w = world(t);
const { land } = await landed(w);
const c = change(w, "other", w.first, "LATER", "on the root");
const r = await pushAs(w, "vikunja:1/3", "other");
assert.equal(r.commit, c);
assert.equal(main(w), land.commit);
});
test("once the root shares no history with a landed main, a push is checked against main, not the root", async (t) => {
const w = world(t);
const { land } = await landed(w);
// The owner starts the root over on an orphan branch, so root and main share nothing.
git(w.root, "checkout", "-q", "--orphan", "fresh");
git(w.root, "rm", "-q", "-rf", ".");
const orphan = commit(w.root, "fresh start");
// Built on main's history: it lands, though the root shares none of it.
const c = change(w, "other", w.first, "LATER", "on main");
const r = await pushAs(w, "vikunja:1/3", "other");
assert.equal(r.commit, c);
assert.equal(main(w), land.commit);
assert.equal(git(w.dir, `--git-dir=${w.repo}`, "rev-parse", "refs/heads/root"), orphan);
// Built on the new root: it shares history with the root only, and refuses.
git(w.clone, "fetch", "-q", "origin");
git(w.clone, "checkout", "-q", "-B", "fresh", "origin/fresh");
commit(w.clone, "on the new root");
await refuses(w, () => pushAs(w, "vikunja:1/4", "fresh"), "unrelated-history");
});
test("a landing squashes the reviewed diff onto main as the bus host and moves main only at finish", async (t) => {
const w = world(t);
change(w, "feat", w.first, "README", "two");
const c2 = commit(w.clone, "three");
await w.go(w.bundle());
const l = landing(w, { commit: c2 });
const land = await lands(w, () => l.prepare());
assert.equal(land.old, w.first);
assert.equal(land.base, w.first);
assert.equal(land.reviewed, c2);
assert.match(land.patch_id, /^[0-9a-f]{40}$/);
assert.equal(land.worktree, join(l.dir, "land-0a1b2c"));
assert.equal(main(w), w.first, "prepare moves nothing");
const at = `--git-dir=${w.repo}`;
assert.equal(git(w.dir, at, "rev-parse", `${land.commit}^@`), w.first, "one parent, the old main");
assert.equal(git(w.dir, at, "rev-parse", `${land.commit}^{tree}`), git(w.dir, at, "rev-parse", `${c2}^{tree}`));
assert.equal(git(w.dir, at, "log", "-1", "--format=%an <%ae>|%cn <%ce>", land.commit), "Mosaic bus host <[email protected]>|Mosaic bus host <[email protected]>");
assert.equal(git(w.dir, at, "log", "-1", "--format=%B", land.commit), message({ old: w.first, base: w.first, patch_id: land.patch_id }).trim());
assert.equal(readFileSync(join(land.worktree, "README"), "utf8"), "three\n");
assert.equal(git(land.worktree, "rev-parse", "HEAD"), land.commit);
assert.equal(git(land.worktree, "status", "--porcelain"), "");
const seen = [];
const done = await lands(w, () => l.finish(land, async (m) => seen.push(m)));
assert.deepEqual(done, { previous: w.first, commit: land.commit });
assert.deepEqual(seen, [done]);
assert.equal(main(w), land.commit);
await l.drop(land.worktree);
assert.equal(existsSync(land.worktree), false);
assert.deepEqual(trees(w), []);
});
test("a landing refuses ref-moved, base-conflict and root-file before it makes a commit", async (t) => {
const w = world(t);
const { c } = await landed(w);
// The verdict approved another commit than the work ref's tip, or a task never pushed.
await refuses(w, () => landing(w, { commit: w.first }).prepare(), "ref-moved");
await refuses(w, () => landing(w, { commit: c, taskRef: "vikunja:1/9" }).prepare(), "ref-moved");
// The same change again no longer applies to main.
await refuses(w, () => landing(w, { commit: c }).prepare(), "base-conflict");
// A change that conflicts with what landed.
const clash = change(w, "clash", w.first, "README", "clash");
await pushAs(w, "vikunja:1/3", "clash");
await refuses(w, () => landing(w, { commit: clash, taskRef: "vikunja:1/3" }).prepare(), "base-conflict");
// An empty diff.
git(w.clone, "checkout", "-q", "-B", "empty", w.first);
git(w.clone, "commit", "-q", "--allow-empty", "-m", "nothing");
const empty = git(w.clone, "rev-parse", "HEAD");
await pushAs(w, "vikunja:1/4", "empty");
await refuses(w, () => landing(w, { commit: empty, taskRef: "vikunja:1/4" }).prepare(), "base-conflict");
// A new file at the root.
const rooty = change(w, "rooty", w.first, "NEW", "root file");
await pushAs(w, "vikunja:1/5", "rooty");
await assert.rejects(landing(w, { commit: rooty, taskRef: "vikunja:1/5" }).prepare(), (e) => e instanceof WorkingError && e.code === "root-file" && /NEW/.test(e.message));
assert.deepEqual(trees(w), [], "no worktree is left");
});
test("a landing whose applied diff isn't the reviewed one refuses patch-drift and leaves no worktree", async (t) => {
const w = world(t);
const c = change(w, "feat", w.first, "README", "two");
await w.go(w.bundle());
// A git that adds a line to what `apply --index` staged, as land-work's test does.
const env = wrapGit(
w,
`"$REAL" "$@"; rc=$?
dir=; apply=0; prev=
for a in "$@"; do [ "$prev" = -C ] && dir=$a; [ "$a" = apply ] && apply=1; prev=$a; done
if [ $apply = 1 ] && [ $rc -eq 0 ]; then printf 'tampered\\n' >> "$dir/README" && "$REAL" -C "$dir" add README; fi
exit $rc`,
);
await assert.rejects(landing(w, { commit: c, env }).prepare(), (e) => e instanceof WorkingError && e.code === "patch-drift" && /^applied [0-9a-f]{40}, reviewed [0-9a-f]{40}$/.test(e.message));
assert.equal(main(w), w.first);
assert.deepEqual(trees(w), [], "no worktree is left");
// The same landing with the real git prepares.
const l = landing(w, { commit: c });
const land = await l.prepare();
assert.equal(land.old, w.first);
await l.drop(land.worktree);
assert.deepEqual(trees(w), []);
});
test("finish refuses base-moved when main moved, and a record that throws moves main back", async (t) => {
const w = world(t);
const c = change(w, "feat", w.first, "README", "two");
await w.go(w.bundle());
const l = landing(w, { commit: c });
const land = await l.prepare();
await assert.rejects(l.finish(land, async () => {
throw new Error("not recorded");
}), /not recorded/);
assert.equal(main(w), w.first);
const at = `--git-dir=${w.repo}`;
git(w.dir, at, "update-ref", "refs/heads/main", c, w.first);
await assert.rejects(l.finish(land), (e) => e instanceof WorkingError && e.code === "base-moved");
assert.equal(main(w), c);
// A lock on main when record() fails, so main can't go back.
git(w.dir, at, "update-ref", "refs/heads/main", w.first, c);
const lock = join(w.repo, "refs/heads/main.lock");
await assert.rejects(l.finish(land, async () => {
writeFileSync(lock, "");
throw new Error("not recorded");
}), (e) => e instanceof WorkingError && e.code === "rollback-failed" && e.message.includes(`main still names ${land.commit}`) && e.message.includes(`back to ${w.first}`));
rmSync(lock);
assert.equal(main(w), land.commit);
await l.drop(land.worktree);
assert.deepEqual(locks(w.repo), []);
assert.deepEqual(trees(w), []);
});
+17 -35
View File
@@ -164,22 +164,14 @@ pdiff "$TARGET" "$MB" "$C" > "$SCRATCH/reviewed.patch"
[ -s "$SCRATCH/reviewed.patch" ] || refuse base-conflict "the reviewed diff $MB..$C is empty"
REVIEWED_ID="$(git -C "$TARGET" patch-id --stable < "$SCRATCH/reviewed.patch" | cut -d' ' -f1)"
# Invariant 1: nothing new at the root. A top-level file, a top-level
# directory the base doesn't have, and the legacy src/ and archived v1/
# trees all count as the root.
mapfile -d '' -t ADDED < <(git -C "$TARGET" diff -z --no-renames --name-only --diff-filter=A "$MB" "$C")
mapfile -d '' -t TOP < <(git -C "$TARGET" ls-tree -z --name-only "$OLD")
ROOT_HITS=""
for f in "${ADDED[@]}"; do
case "$f" in
*/*) top="${f%%/*}" ;;
*) ROOT_HITS="$ROOT_HITS $f"; continue ;;
esac
known="no"
for t in "${TOP[@]}"; do [ "$t" = "$top" ] && known="yes" && break; done
if [ "$known" = "no" ] || [ "$top" = "src" ] || [ "$top" = "v1" ]; then ROOT_HITS="$ROOT_HITS $f"; fi
done
[ -z "$ROOT_HITS" ] || refuse root-file "the reviewed diff adds$ROOT_HITS"
# Invariant 1: nothing new at the root. The rule is rootHits in
# packages/seat/src/landing.mjs, which the bus host's landings share.
git -C "$TARGET" diff -z --no-renames --name-only --diff-filter=A "$MB" "$C" > "$SCRATCH/added"
git -C "$TARGET" ls-tree -z --name-only "$OLD" > "$SCRATCH/top"
ROOT_RC=0
node "$REPO/packages/seat/src/landing.mjs" root-file "$SCRATCH/top" "$SCRATCH/added" > "$SCRATCH/root-hits" || ROOT_RC=$?
[ "$ROOT_RC" -ne 2 ] || refuse root-file "the reviewed diff adds $(tr '\n' ' ' < "$SCRATCH/root-hits" | sed 's/ $//')"
[ "$ROOT_RC" -eq 0 ] || fail "the root-file check could not run (exit $ROOT_RC)"
mapfile -d '' -t PATHS < <(git -C "$TARGET" diff -z --no-renames --name-only "$MB" "$C")
# Paths go to git as they are, never as globs or pathspec magic.
@@ -214,25 +206,15 @@ echo "land-work: gate logs in $LOGS" >&2
# to shorten or skip it.
[ -d "$TARGET/node_modules" ] || fail "no node_modules in $TARGET for the gate" 4
ln -s "$TARGET/node_modules" "$WT/node_modules"
gate_step() { # name dir command
local rc=0
(cd "$WT/$2" && bash -c "set -o pipefail; $3") > "$LOGS/$1.log" 2>&1 || rc=$?
echo "$1 rc=$rc $(grep -E '^ℹ (pass|fail) [0-9]+' "$LOGS/$1.log" | tr '\n' ' ')" | tee -a "$LOGS/gate.log" >&2
[ "$rc" -eq 0 ] || refuse gate-failed "$1, see $LOGS/$1.log"
}
# No pkg-queue step: scripts/test-queue.sh runs packages/queue/tests/ itself.
for d in "$WT"/packages/*/; do
p="$(basename "$d")"
[ -d "$d/tests" ] || continue
[ "$p" = queue ] && continue
gate_step "pkg-$p" "packages/$p" 'node --test tests/*.test.mjs'
done
for s in "$WT"/scripts/test-*.sh; do
[ -f "$s" ] || continue
n="$(basename "$s" .sh)"
gate_step "script-${n#test-}" . "bash scripts/$n.sh"
done
echo "GATE_PASSED" | tee -a "$LOGS/gate.log" >&2
# The steps, their logs and the summary lines are runGate's in
# packages/seat/src/landing.mjs, which the bus host's landings share.
GATE_RC=0
node "$REPO/packages/seat/src/landing.mjs" gate "$WT" "$LOGS" > "$SCRATCH/gate.out" || GATE_RC=$?
if [ "$GATE_RC" -eq 2 ] && [ -s "$SCRATCH/gate.out" ]; then
line="$(head -n1 "$SCRATCH/gate.out")"
refuse gate-failed "${line%% *}, see ${line#* }"
fi
[ "$GATE_RC" -eq 0 ] || fail "the gate could not run (exit $GATE_RC)"
# ---- the commit ----
SHORT_ID="${REVIEWED_ID:0:12}"
+11 -124
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
// land-work's read of the bus (row 116, #1610). Every query land-work runs
// against bus.sqlite is in this file, and every one is a SELECT.
// land-work's read of the bus (row 116, #1610). The checks themselves are in
// packages/bus/src/landing.mjs, which the broker's agent landings share
// (row 137, #1631); every query there is a SELECT.
//
// node scripts/land-work/read.mjs <dataRoot> <business-file> <decision-id> <base>
//
@@ -17,27 +18,10 @@ import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createHash } from 'node:crypto';
import { schemaDigest, SCHEMA_SHA256, SCHEMA_VERSION } from '../../packages/bus/src/store.mjs';
import { LandingRefusal, readLanding as shared } from '../../packages/bus/src/landing.mjs';
const MERGE = 'git.merge.protected';
const WORK_REF = /^work\/vikunja-([1-9][0-9]*)-([1-9][0-9]*)$/;
const ANY_WORK_REF = /\bwork\/[A-Za-z0-9._/-]*[A-Za-z0-9_]/g;
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
class Refusal extends Error {
constructor(reason, detail) {
super(detail);
this.reason = reason;
}
}
const refuse = (reason, detail) => {
throw new Refusal(reason, detail);
};
const json = (text) => {
try {
return JSON.parse(text);
} catch {
return null;
}
throw new LandingRefusal(reason, detail);
};
export function openBus(dataRoot) {
@@ -82,109 +66,12 @@ export function checkSchema(db) {
refuse('bus-schema', `meta says version ${meta('schema_version')}, digest ${String(meta('schema_digest')).slice(0, 12)}`);
}
// The queries. Each takes the business so one bus can't answer for another.
const queries = {
decision: (db, b, id) => db.prepare('SELECT * FROM decisions WHERE business = ? AND id = ?').get(b, id),
raise: (db, b, id) =>
db
.prepare(
`SELECT * FROM events WHERE business = ? AND kind = 'action.allowed' AND subject = ?
AND json_extract(body, '$.operation') = 'decision.raise' AND json_extract(body, '$.decision') = ?
ORDER BY seq`,
)
.all(b, id, id),
closes: (db, id) =>
db
.prepare(
`SELECT * FROM decision_events WHERE decision = ? AND op IN ('resolved','withdrawn','expired') ORDER BY seq`,
)
.all(id),
answer: (db, b, id) =>
db
.prepare(
`SELECT * FROM events WHERE business = ? AND kind = 'human.input' AND subject = ?
AND json_extract(body, '$.kind') = 'answer' AND json_extract(body, '$.decision') = ?
AND json_extract(body, '$.op') = 'resolved'
ORDER BY seq`,
)
.all(b, id, id),
later: (db, b, d) =>
db
.prepare(
`SELECT * FROM decisions WHERE business = ? AND id <> ?
AND (supersedes = ? OR (seq > ? AND action = ?)) ORDER BY seq`,
)
.all(b, d.id, d.id, d.seq, MERGE),
verdict: (db, b, task) =>
db
.prepare(`SELECT * FROM events WHERE business = ? AND kind = 'review.verdict' AND subject = ? ORDER BY seq DESC LIMIT 1`)
.get(b, task),
};
// land-work lands only on refactor (#1610): the base it was given must be
// refactor, and the raise must target it.
export function readLanding(db, business, id, base) {
const b = business.id;
const d = queries.decision(db, b, id);
const raises = queries.raise(db, b, id);
if (!d || raises.length !== 1) refuse('decision-not-found', `no decision ${id} with one raise in business ${b}`);
const raise = json(raises[0].body) ?? {};
if (d.action !== MERGE || raise.action !== MERGE) refuse('not-a-merge', `action ${d.action}`);
const closes = queries.closes(db, id);
if (closes.length !== 1 || closes[0].op !== 'resolved')
refuse('not-resolved', closes.length ? closes.map((r) => r.op).join(',') : 'open');
const res = closes[0];
const answers = queries.answer(db, b, id);
const answer = answers.length === 1 ? answers[0] : null;
if (
res.by !== business.human ||
res.via !== 'cli' ||
!answer ||
answer.actor_role !== null ||
answer.actor_run !== null ||
json(answer.body)?.choice !== res.choice
)
refuse('not-human', `resolved by ${res.by} via ${res.via}, ${answers.length} human answer(s)`);
const options = json(d.options) ?? [];
const merge = options.find((o) => o?.key === raise.approvalChoice);
if (!raise.approvalChoice || !merge) refuse('not-merge-option', 'the raise names no approval option');
if (res.choice !== raise.approvalChoice)
refuse('not-merge-option', `chose ${res.choice}, the merge option is ${raise.approvalChoice}`);
// The work ref comes from the question: decisions don't carry it.
const named = [...new Set(d.question.match(ANY_WORK_REF) ?? [])];
const m = named.length === 1 ? WORK_REF.exec(named[0]) : null;
if (!m) refuse('work-ref', `the question names ${named.join(', ') || 'no work ref'}`);
const workRef = named[0];
const task = `vikunja:${m[1]}/${m[2]}`;
for (const ref of [d.task_ref, raise.task_ref])
if (ref != null && ref !== task) refuse('work-ref', `the decision cites ${ref}, the question ${workRef}`);
// The target is the raise's, and only refactor ever lands.
if (raise.target !== 'refactor' || raise.target !== base)
refuse('target-branch', `the raise targets ${raise.target}, the base is ${base}`);
const later = queries.later(db, b, d).filter(
(r) => r.supersedes === id || r.task_ref === task || (r.question.match(ANY_WORK_REF) ?? []).includes(workRef),
);
if (later.length) refuse('superseded', `later decision ${later.map((r) => r.id).join(', ')} on ${workRef}`);
const v = queries.verdict(db, b, task);
if (!v) refuse('no-verdict', `no review.verdict for ${task}`);
const vb = json(v.body) ?? {};
if (vb.verdict !== 'approve' || vb.task_ref !== task || !OID.test(vb.commit ?? ''))
refuse('not-approved', `latest verdict ${v.id} is ${vb.verdict} on ${vb.commit}`);
return {
task_ref: task,
work_ref: workRef,
commit: vb.commit,
verdict: v.id,
project: d.project ?? '',
choice: res.choice,
by: res.by,
resolved_at: res.at,
subject: String(merge.text ?? '').split('\n')[0].trim(),
};
if (base !== 'refactor') refuse('target-branch', `the base is ${base}, land-work lands only on refactor`);
const q = { get: (sql, ...p) => db.prepare(sql).get(...p), all: (sql, ...p) => db.prepare(sql).all(...p) };
return shared(q, business, id, { into: 'refactor' });
}
function main([dataRoot, businessFile, id, base]) {
@@ -206,7 +93,7 @@ function main([dataRoot, businessFile, id, base]) {
for (const [k, v] of Object.entries(readLanding(db, business, id, base))) out(k, v);
return 0;
} catch (e) {
if (e instanceof Refusal) {
if (e instanceof LandingRefusal) {
out('refuse', `${e.reason}\t${e.message}`);
return 2;
}