agents: Sage schema window plan update and P19 task 304 filing

The window plan records row 49 landed and row 121's round 1 changes, and
adds Darkwing's 28587 note 2: the first start reads every comment list.
Task 304 is Darkwing's note 1, push a task's comment cursor only when
consider runs for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
jason.woltjeandClaude Opus 5.5 committed 2026-10-15 12:52:06 -05:00
1 parent 48b8938719
commit b6a588cd20
3 files changed
+91 -2

No files matched your search

@@ -0,0 +1,17 @@
{
"at": "2026-10-15T17:34:55.119Z",
"project": 19,
"created": [
{
"title": "tasks: push a task's comment cursor only when consider runs for that task",
"id": 304,
"index": 145,
"inverse": "mark done: POST /api/v1/tasks/304 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,63 @@
// Sage, 2026-10-15: decision 110. Files one follow-up as a task in project 19 with Sage's own key (bot
// user 13): a comment on a task that raced a move gets no event (row 49 r1, #1535, Darkwing 28587). 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-r49-followup-2.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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const p = (...xs) => xs.map((x) => `<p>${esc(x)}</p>`).join("") + `<p>${esc(SIG)}</p>`;
const TASKS = [
{ title: "tasks: push a task's comment cursor only when consider runs for that task",
description: p("Darkwing's row 49 round 1 approve (#1535, comment 28587), non-blocking. In tick, a hit that is open but not on any board read is skipped ('the next board read places it'). foreignComments has already pushed that task's cursor, the cursor commits, and no event names the comment. The next read starts above that id, so the comment never produces an event. Base lost the same comment in memory; the persisted cursor makes the loss permanent. Found by reading, not probed.",
"Push the cursor only when consider runs for the task. First a probe or test that shows the loss, then the fix, and one mutant that pushes the cursor early again. Do it after row 49 lands; check it together with task 300, which touches the same loop.") },
];
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);
@@ -8,10 +8,10 @@ the migration from agents/rocko/work/queue-49/r1/MIGRATION.md inserted.
| Row | Issue | Schema step | State today |
|-----|-------|-------------|-------------|
| 49 | #1535 | 3b → 3c, comment cursors and the migrator | Filbert approved (28579); Darkwing's verdict pending |
| 49 | #1535 | 3b → 3c, comment cursors and the migrator | landed 3a4afdc03 (Filbert 28579, Darkwing 28587) |
| 123 | #1617 | 3c → 3d, revoke with a reason | Filbert approved r2 (28578); lands after 49 |
| 132 | #1628 | none, harness tools for the chief | queued; must land before 3d goes live (Sage, #1617 comment 28565) |
| 121 | #1615 | 3d → 3e | Darkwing building on eaeb5ebf2; lands after 49 and 123 |
| 121 | #1615 | 3d → 3e | r1 changes (Dewey 28591); round 2 rebases on 49, 123 and 133 |
One window, after all four land. `migrate-schema.mjs` chains the steps in
order, each committed version a resume boundary, so one call takes the live
@@ -44,6 +44,15 @@ the migration right after stopping the bus, backup in between, and doesn't
hold the store stopped and unmigrated. Later outages use stored ids and
have no such limit.
## First start reads every comment list (Darkwing, 28587 note 2)
The first reconcile after any bus start has no cached comment count, so
it reads the comment list of every open or done task in the tree: one
tracker request per task. That is how outage comments are found. At the
window's start step, expect a burst of requests to tasks.woltje.com. If
the bus log shows rate-limit refusals, record them and tell Sage. Don't
restart the bus to clear them: every restart repeats the burst.
## Checks to add to the window
1. Before the stop: `stack-copy.sh copy NEW`, as usual. Confirm NEW contains