Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"at": "2026-10-12T23:46:26.851Z",
|
||||
"project": 19,
|
||||
"created": [
|
||||
{
|
||||
"title": "Launcher recover() releases a role before its killed session is gone (row 91 finding)",
|
||||
"id": 244,
|
||||
"index": 91,
|
||||
"inverse": "mark done: POST /api/v1/tasks/244 done true (pm); delete is Jason's or ops-01's",
|
||||
"readBack": 200,
|
||||
"project_back": 19,
|
||||
"due_back": "0001-01-01T00:00:00Z",
|
||||
"done": false
|
||||
}
|
||||
],
|
||||
"problems": []
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Sage, 2026-10-12: decision 110. Files row 91's launcher recover() finding (#1585, decision 128) as one task in project 19
|
||||
// with Sage's own key (bot user 13). Refuses if the title already exists. The sync key reads it back. No
|
||||
// labels, no assignees, no bucket moves. Rollback is marking the task done; deletion is Jason's or ops-01's.
|
||||
// Prints no secret.
|
||||
// Usage: node file-r91-recover.mjs https://tasks.woltje.com SAGE_TOKEN_FILE SYNC_TOKEN_FILE OUT_JSON
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
const [ORIGIN, KEY, SYNC, OUT] = process.argv.slice(2);
|
||||
if (!/^https:\/\/tasks\.woltje\.com$/.test(ORIGIN)) throw new Error("tasks.woltje.com only");
|
||||
if (!OUT || existsSync(OUT)) throw new Error("OUT_JSON missing or exists; refusing");
|
||||
const PROJECT = 19;
|
||||
const SIG = "Created by Sage (T3 1ef1e4f8) with Sage's own key (bot user 13).";
|
||||
const esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
const p = (...xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(SIG)}</p>`;
|
||||
const TASKS = [
|
||||
{ title: "Launcher recover() releases a role before its killed session is gone (row 91 finding)",
|
||||
description: p("Darkwing found this building row 91 (Gitea #1585, build.md, Found, not fixed). recover() in packages/cli/src/launcher.mjs ends a leftover run and releases its role 5 s after SIGKILL even when the process has not exited. Row 91 relies on registry.running for decision 126's rule that no host git runs while a session holds the workspace, so a relaunch of that instance could reach ensureCheckouts while the old process still writes the clone.",
|
||||
"Fix: release only after the process (and any sandbox descendants) is confirmed gone, or keep the role held and refuse the relaunch with a named reason. Test it with a stand-in process that ignores the first signal window.",
|
||||
"Decision 128: not a blocker for row 91, because the window needs a bus start with a leftover session whose SIGKILL has not taken effect in 5 s. Needs a queue row and brief; it should land before row 91's clone is used by sessions in daily work. Touches packages/cli, so decision 109's notice to Mos applies.") },
|
||||
];
|
||||
const key = readFileSync(KEY, "utf8").trim(), sync = readFileSync(SYNC, "utf8").trim();
|
||||
async function api(method, path, body, auth) {
|
||||
const r = await fetch(ORIGIN + path, { method, headers: { Authorization: `Bearer ${auth}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), redirect: "error" });
|
||||
const t = await r.text(); let json = null; try { json = JSON.parse(t); } catch {}
|
||||
return { status: r.status, json };
|
||||
}
|
||||
const list = (j) => j?.items ?? (Array.isArray(j) ? j : []);
|
||||
async function existing() {
|
||||
const out = [];
|
||||
for (let page = 1; page < 20; page++) {
|
||||
const r = await api("GET", `/api/v2/projects/${PROJECT}/tasks?per_page=50&page=${page}`, undefined, sync);
|
||||
if (r.status !== 200) throw new Error(`list tasks: ${r.status}`);
|
||||
out.push(...list(r.json));
|
||||
if (list(r.json).length < 50) return out;
|
||||
}
|
||||
throw new Error("more than 19 pages");
|
||||
}
|
||||
const rec = { at: new Date().toISOString(), project: PROJECT, created: [], problems: [] };
|
||||
const save = () => {
|
||||
const out = JSON.stringify(rec, null, 1) + "\n";
|
||||
if (out.includes(key) || out.includes(sync)) throw new Error("secret in the record; not written");
|
||||
writeFileSync(OUT, out);
|
||||
};
|
||||
const before = await existing();
|
||||
const clash = before.filter((t) => TASKS.some((x) => x.title === t.title));
|
||||
if (clash.length) throw new Error(`exists: ${clash.map((t) => `#${t.id} ${t.title}`).join("; ")}; refusing`);
|
||||
writeFileSync(OUT, "", { flag: "wx" });
|
||||
|
||||
for (const t of TASKS) {
|
||||
rec.created.push({ title: t.title });
|
||||
save();
|
||||
const body = { title: t.title, description: t.description, ...(t.due ? { due_date: t.due } : {}) };
|
||||
const r = await api("PUT", `/api/v1/projects/${PROJECT}/tasks`, body, key);
|
||||
if (r.status >= 300 || !Number.isSafeInteger(r.json?.id)) {
|
||||
rec.problems.push(`create ${t.title}: ${r.status} ${r.json?.code ?? ""} ${r.json?.message ?? ""}`);
|
||||
break;
|
||||
}
|
||||
Object.assign(rec.created.at(-1), { id: r.json.id, index: r.json.index, inverse: `mark done: POST /api/v1/tasks/${r.json.id} done true (pm); delete is Jason's or ops-01's` });
|
||||
const back = await api("GET", `/api/v2/tasks/${r.json.id}`, undefined, sync);
|
||||
Object.assign(rec.created.at(-1), { readBack: back.status, project_back: back.json?.project_id, due_back: back.json?.due_date, done: back.json?.done });
|
||||
if (back.status !== 200 || back.json?.title !== t.title || back.json?.project_id !== PROJECT || back.json?.done) rec.problems.push(`${t.title} read-back differs`);
|
||||
}
|
||||
save();
|
||||
process.stdout.write(JSON.stringify(rec, null, 1) + "\n");
|
||||
if (rec.problems.length) process.exit(2);
|
||||
@@ -2857,3 +2857,25 @@ which stay with him. Each item names who decided it and what happened.
|
||||
between changed what a file's imports are allowed to load.
|
||||
- Harness runs on the 21-path candidate in Filbert's review and in
|
||||
Sage's landing gate, not in a separate Dewey slot.
|
||||
|
||||
128. **The launcher's recover() gap is a follow-up, not a row 91 blocker
|
||||
(2026-10-12).** Darkwing found it building row 91 (build.md, "Found,
|
||||
not fixed"). `recover()` in `packages/cli/src/launcher.mjs` sends
|
||||
SIGKILL to a leftover bwrap session, waits up to 5 s, then binds and
|
||||
ends the run whether or not the process is gone. Ending the run
|
||||
releases the role, so `registry.running` no longer refuses
|
||||
`instance-running`, and a launch of that instance could reach
|
||||
`ensureCheckouts` while the old process still writes the clone.
|
||||
That is the check-then-use gap decision 126 closed for live sessions.
|
||||
- Not a blocker for row 91: it needs a bus start that finds a leftover
|
||||
session whose SIGKILL hasn't taken effect within 5 s (a process
|
||||
stuck in uninterruptible sleep, for one). Row 91 doesn't widen it.
|
||||
- Filed as Vikunja #244 (P19). The fix releases the role only once the
|
||||
process is confirmed gone, or keeps it held and refuses the relaunch
|
||||
with a named reason, tested with a stand-in that outlives the window.
|
||||
It gets a queue row and brief before sessions use row 91's clone in
|
||||
daily work. It touches `packages/cli`, so decision 109's notice to
|
||||
Mos applies.
|
||||
- Filbert's row 91 review says whether the session-live guard depends
|
||||
on anything else that assumes the role release means the process is
|
||||
gone.
|
||||
|
||||
Reference in New Issue
Block a user