cli: row 199 sage-1440: publishResult's poll is bounded by publishDeadlineMs (four times git's limit and grace plus 30 s) and refuses publish-timeout, filed once; end-to-end tests for ref-moved and decision-mismatch mid-flight and for the timeout; DESIGN.md T3 now says such a push refuses; BUILD.md deviations 11, 13 and 14 ruled (#1697)
This commit is contained in:
@@ -79,10 +79,14 @@ this branch, apart from one slip noted under "Runs".
|
||||
refuses `publish-busy`. The token reaches git on fd 3 only (`runGit` option `pass`), read once
|
||||
by a one-shot credential helper that answers `get` for https on the fork's exact host and port.
|
||||
On close, a running publish's git isn't awaited; git's own time limit ends it, and the ticket
|
||||
goes with the process. A failure that is not a refusal is filed as `git-failed` and logged on
|
||||
goes with the process. The 10 s IPC limit stays as it is (sage-1440). The poll has a bound
|
||||
(sage-1440): `publishDeadlineMs`, by default four times git's limit and grace (publishRemote runs
|
||||
at most four git commands) plus 30 s. Past it the socket refuses `publish-timeout`, filed once by
|
||||
publishRefused, and logs that the bus process's push may still reach the fork. A failure that is not a refusal is filed as `git-failed` and logged on
|
||||
the bus's stderr by error name and code only. Tests: `seat/tests/publish.test.mjs` (11),
|
||||
`bus/tests/publish.test.mjs` (the process ops), `cli/tests/publish-socket.test.mjs` (4, end to
|
||||
end through the socket, the in-process runtime, the store and a TLS fake Gitea).
|
||||
`bus/tests/publish.test.mjs` (the process ops), `cli/tests/publish-socket.test.mjs` (7, end to
|
||||
end through the socket, the in-process runtime, the store and a TLS fake Gitea; one is the
|
||||
publish-timeout outcome).
|
||||
12. **No harness tool for publish.** `packages/harness/src/git-tools.mjs` has push, fetch and land
|
||||
clients. A runner tool that calls the socket's `publish` op is in packages/harness, outside the
|
||||
manifest scope sage-1421 set. Today a publish needs a socket client; the tool is a follow-up.
|
||||
@@ -90,13 +94,18 @@ this branch, apart from one slip noted under "Runs".
|
||||
launcher has it. A role can publish only a push its own role made (`work-not-yours`), and
|
||||
`recordPush` needs `git.push.working`, which already binds the socket. No role reaches the
|
||||
clause alone, so dropping it changes no outcome today. It matters once a role can publish work
|
||||
it didn't push.
|
||||
it didn't push. Ruled in sage-1440: keep it; its mutant goes in the section 11 list as
|
||||
equivalent, with this reasoning, and Duckworth judges it.
|
||||
14. **A push between checkPublish and the git push refuses; it never lands a different commit.**
|
||||
T3 says such a push "doesn't change the commit that lands". Under the hard wait it refuses:
|
||||
`working.publish` checks the work ref's tip against the ticket's source under the lock
|
||||
(`ref-moved`, `seat/tests/publish.test.mjs`), and `recordPublish` checks `pushCommit` again
|
||||
(`decision-mismatch`, `bus/tests/publish.test.mjs` "r4 B4"). The fork gets only the ticket's
|
||||
oid either way. N9's routing-commit test still applies.
|
||||
oid either way. N9's routing-commit test still applies. Ruled in sage-1440: T3's wording in
|
||||
DESIGN.md now reads "refuses with `ref-moved` or `decision-mismatch`", and an end-to-end test
|
||||
asserts each code mid-flight in `cli/tests/publish-socket.test.mjs`: a push after checkPublish
|
||||
refuses `ref-moved` with no request to the fork; a push recorded while the bus process pushes
|
||||
refuses `decision-mismatch`, filed once, and the fork holds only the ticket's oid.
|
||||
|
||||
## N9, for the routing commit (sage-1388)
|
||||
|
||||
@@ -140,6 +149,9 @@ checkout (77), working (26), publish (11), landing (8); bus process (8), land (1
|
||||
credential-verbs (2); cli publish-socket (4), git-socket (24), launcher (29); harness tools (22).
|
||||
Hash pins: none on a changed file.
|
||||
|
||||
sage-1440 changes (the poll bound, the mid-flight tests), single files, green before the commit: cli
|
||||
publish-socket (7), git-socket (24), launcher (29). Hash pins: none on a changed file.
|
||||
|
||||
Slip, 2026-10-20 about 19:51Z: I ran `node --test packages/tasks/tests/*.test.mjs` in one command,
|
||||
which is a package suite, without asking Sage for the slot. It passed, 97 pass and 0 fail, in under a
|
||||
minute. Reported to Sage in megavolt-0017.
|
||||
|
||||
@@ -374,7 +374,7 @@ All tests use scratch roots, fake tokens and a fake Gitea: `git http-backend` be
|
||||
no decision refuses, one with a decision for another task or commit refuses, and a second publish on a
|
||||
spent decision refuses `decision-consumed`. Approve while the latest push is A, push B, publish: refuses
|
||||
`decision-mismatch`, and the fork receives nothing. A push to the working repo between `checkPublish` and
|
||||
the git push doesn't change the commit that lands. A raise with no recorded push refuses `no-push`. A test pins that `git.push.remote` is in neither ROUTED list
|
||||
the git push refuses with `ref-moved` or `decision-mismatch` (sage-1440, BUILD.md deviation 14). A raise with no recorded push refuses `no-push`. A test pins that `git.push.remote` is in neither ROUTED list
|
||||
and that no `roles/*.json` grants it. A force-push attempt is
|
||||
refused by git's non-fast-forward. A fake fork that answers 302 to a second 127.0.0.1 port: the push
|
||||
fails, and the second port receives no request with an Authorization header. A remote on the right host
|
||||
|
||||
@@ -35,8 +35,10 @@
|
||||
// the repository's lock, then the bus process runs ls-remote and the
|
||||
// push with the agent's gitea token (publishPush), and the host polls
|
||||
// publishResult every publishPollMs: the host's op channel answers
|
||||
// one op at a time within 10 s. recordPublish files the push; any
|
||||
// refusal after checkPublish is filed once, by publishRefused.
|
||||
// one op at a time within 10 s. The poll ends at publishDeadlineMs
|
||||
// (publish-timeout), past git's own limit on each of the four git
|
||||
// runs. recordPublish files the push; any refusal after checkPublish
|
||||
// is filed once, by publishRefused.
|
||||
//
|
||||
// A refusal is {"ok": false, "error": "<code>"}, and the connection ends.
|
||||
// The host checks, in order: identity (a role of this business), the request
|
||||
@@ -75,6 +77,7 @@ import { randomBytes } from "node:crypto";
|
||||
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 { GIT_GRACE_MS, GIT_TIMEOUT_MS } from "../../seat/src/checkout.mjs";
|
||||
import { gitSocketPath } from "../../seat/src/session.mjs";
|
||||
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
|
||||
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
|
||||
@@ -97,6 +100,9 @@ const MAX_ACTIVE = 8;
|
||||
export const CLOSE_WAIT_MS = 2000;
|
||||
// How often the host asks the bus process whether a publish's push ended.
|
||||
const PUBLISH_POLL_MS = 200;
|
||||
// The longest the host polls: publishRemote runs at most four git commands
|
||||
// (ls-remote, cat-file, merge-base, push), each ended by git's own limit.
|
||||
const PUBLISH_DEADLINE_MS = 4 * (GIT_TIMEOUT_MS + GIT_GRACE_MS) + 30_000;
|
||||
const CAP = /^[0-9a-f]{64}$/;
|
||||
const OID = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
||||
|
||||
@@ -285,7 +291,7 @@ Applied-under: git socket land (bus host)
|
||||
// host: startHost()'s handle; business: loadBusiness() output. bundleMax,
|
||||
// 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, publishPollMs = PUBLISH_POLL_MS }) {
|
||||
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, publishPollMs = PUBLISH_POLL_MS, publishDeadlineMs = PUBLISH_DEADLINE_MS }) {
|
||||
const path = gitSocketPath(dataRoot);
|
||||
const dir = join(dataRoot, "bus-host");
|
||||
let server = null;
|
||||
@@ -419,7 +425,14 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
dataRoot, business: b.id, project: req.project, root: p.root, taskRef: task_ref, source: facts.source, email: facts.email, env,
|
||||
remote: async () => {
|
||||
await op({ op: "publishPush", ticket });
|
||||
const until = Date.now() + publishDeadlineMs;
|
||||
for (;;) {
|
||||
// The bus's git may still finish after this; the fork could then hold the commit with
|
||||
// no record, as when recordPublish refuses after a push.
|
||||
if (Date.now() >= until) {
|
||||
log(`git socket: publish ${task_ref}: publish-timeout; the bus process's push may still reach the fork`);
|
||||
throw new Refusal("publish-timeout");
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, publishPollMs));
|
||||
const r = await op({ op: "publishResult", ticket });
|
||||
if (r.state === "pushed") return { ref: r.ref, commit: r.commit, previous: r.previous };
|
||||
|
||||
@@ -14,6 +14,7 @@ import { startBroker } from "../../bus/src/runtime.mjs";
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from "../../business/tests/helpers.mjs";
|
||||
import { pushWorking } from "../../harness/src/git-tools.mjs";
|
||||
import { gitEnv } from "../../seat/src/checkout.mjs";
|
||||
import { workRef } from "../../seat/src/working.mjs";
|
||||
import { fakeGitea, scratchCa } from "../../seat/tests/fake-gitea.mjs";
|
||||
import { createGitSocket } from "../src/git-socket.mjs";
|
||||
|
||||
@@ -56,7 +57,7 @@ const OPS = {
|
||||
publishRefused: (r, m) => r.publishRefused(m.ticket, m.args),
|
||||
};
|
||||
|
||||
async function setup(t) {
|
||||
async function setup(t, { publishDeadlineMs } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "cli-publish-"));
|
||||
const down = [];
|
||||
t.after(async () => {
|
||||
@@ -100,10 +101,12 @@ async function setup(t) {
|
||||
const dataRoot = join(root, "data");
|
||||
const bus = await startBroker({ dataRoot, businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) }, publishTls: ["-c", `http.sslCAInfo=${ca.ca}`] });
|
||||
down.unshift(() => bus.close());
|
||||
const host = { op: async (m) => OPS[m.op](bus, m) };
|
||||
// A test sets hooks[op] to act around one op: hook(message, next), where next() answers it.
|
||||
const hooks = {};
|
||||
const host = { op: async (m) => (hooks[m.op] ? hooks[m.op](m, () => OPS[m.op](bus, m)) : OPS[m.op](bus, m)) };
|
||||
const logs = [];
|
||||
mkdirSync(join(dataRoot, "bus-host"), { mode: 0o700 });
|
||||
const sock = createGitSocket({ host, business: demo, dataRoot, env: process.env, log: (l) => logs.push(l), publishPollMs: 20 });
|
||||
const sock = createGitSocket({ host, business: demo, dataRoot, env: process.env, log: (l) => logs.push(l), publishPollMs: 20, ...(publishDeadlineMs ? { publishDeadlineMs } : {}) });
|
||||
down.unshift(() => sock.close());
|
||||
await sock.listen();
|
||||
|
||||
@@ -127,6 +130,13 @@ async function setup(t) {
|
||||
await pushWorking({ path: sock.path, cap: caps[role], workspace, tmp, task_ref: task, project: "stack", branch: "feat" });
|
||||
return commitId;
|
||||
};
|
||||
// A second commit on the coder's feat, pushed to the working repo through the socket.
|
||||
const again = async () => {
|
||||
const workspace = join(root, "ws-coder");
|
||||
const commitId = commit(join(workspace, "stack"), "more work", "[email protected]");
|
||||
await pushWorking({ path: sock.path, cap: caps.coder, workspace, tmp: join(root, "tmp-coder"), task_ref: TASK, project: "stack", branch: "feat" });
|
||||
return commitId;
|
||||
};
|
||||
const approve = (role = "coder", task = TASK) => {
|
||||
const d = bus.broker.request(caps[role], { verb: "decision.raise", args: {
|
||||
action: "git.push.remote", target: task, task_ref: task, question: "Publish?", blocking: false, recommendation: "yes",
|
||||
@@ -145,7 +155,7 @@ async function setup(t) {
|
||||
db.close();
|
||||
}
|
||||
};
|
||||
return { root, bus, gitea, forkRepo, work: await work("coder", TASK), more: work, approve, publish, logs, events };
|
||||
return { root, bus, gitea, forkRepo, work: await work("coder", TASK), more: work, again, hooks, caps, approve, publish, logs, events };
|
||||
}
|
||||
|
||||
test("T3 end to end: publish under an approved decision sends the work commit to the agent's own fork and records it once", async (t) => {
|
||||
@@ -222,3 +232,50 @@ test("T3 end to end: two agents, one project, each lands on its own fork only; t
|
||||
assert.equal(refs("second").includes("refs/heads/coder/"), false);
|
||||
assert.equal(refs("mosaic").split("\n").length, 1, "the upstream holds only main");
|
||||
});
|
||||
|
||||
// sage-1440 on BUILD.md 14: a push between checkPublish and the git push refuses, with one code
|
||||
// for each place it can come in, and the fork never receives a commit the decision didn't bind.
|
||||
test("T3: a push after checkPublish and before the lock refuses ref-moved; the fork receives nothing", async (t) => {
|
||||
const w = await setup(t);
|
||||
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
|
||||
w.hooks.checkPublish = async (m, next) => {
|
||||
const facts = await next();
|
||||
await w.again();
|
||||
return facts;
|
||||
};
|
||||
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "ref-moved" });
|
||||
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["ref-moved"]);
|
||||
assert.equal(w.gitea.requests.length, 0);
|
||||
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "for-each-ref", "refs/heads/coder/"), "");
|
||||
});
|
||||
|
||||
test("T3: a push recorded while the bus process pushes refuses decision-mismatch; only the decision's commit reached the fork", async (t) => {
|
||||
const w = await setup(t);
|
||||
w.bus.credentials.issue("demo/coder", "gitea", TOKEN, "2099-01-01");
|
||||
// The working repo's lock is held through the git push, so a socket push would wait for it; the
|
||||
// broker's record of a newer push is what a push that won the lock leaves.
|
||||
w.hooks.publishPush = async (m, next) => {
|
||||
w.bus.checkPush(w.caps.coder, TASK);
|
||||
w.bus.recordPush(w.caps.coder, { task_ref: TASK, project: "stack", branch: "feat", ref: workRef(TASK), commit: "c".repeat(40), previous: w.work });
|
||||
return next();
|
||||
};
|
||||
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "decision-mismatch" });
|
||||
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["decision-mismatch"]);
|
||||
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
|
||||
assert.equal(git(w.root, `--git-dir=${w.forkRepo}`, "rev-parse", "refs/heads/coder/feat"), w.work);
|
||||
assert.ok(w.logs.some((l) => l.includes("wasn't recorded")));
|
||||
});
|
||||
|
||||
test("sage-1440: the publishResult poll is bounded: publish-timeout, filed once, and nothing recorded", async (t) => {
|
||||
const w = await setup(t, { publishDeadlineMs: 150 });
|
||||
let polls = 0;
|
||||
w.hooks.publishResult = async () => {
|
||||
polls++;
|
||||
return { state: "running" };
|
||||
};
|
||||
assert.deepEqual(await w.publish(w.approve()), { ok: false, error: "publish-timeout" });
|
||||
assert.ok(polls > 0);
|
||||
assert.deepEqual(w.events("action.refused").map((e) => e.body.code), ["publish-timeout"]);
|
||||
assert.equal(w.events("action.allowed").filter((e) => e.body.action === "git.push.remote" && e.body.operation !== "decision.raise").length, 0);
|
||||
assert.ok(w.logs.some((l) => l.includes("publish-timeout")));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user