Compare commits

...
9 changed files with 567 additions and 7 deletions

No files matched your search

+2
View File
@@ -6007,3 +6007,5 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr
2026-11-04T02:45Z | Claude Sonnet 5.5 implementation sub-agent, t3/lifecycle-bootstrap-fix-round | #797 / Gitea #1839 lifecycle Task 2 fix round | One fix round on c55849806 for review findings C1, C2, I1, I2, I3, I4; Minor items, Tasks 3-8, roles/*.json, ROUTED and all migrations untouched (no schema change). C1: seeding no longer follows a committed .mosaic link. Linked ancestors and non-regular metadata are refused in the source and in the clone, and the seed and the marker temp are written with O_EXCL|O_NOFOLLOW and renamed, so a planted link is never followed. C2: the clone goes into an exclusively created code.tmp backed by a code.tmp.claim receipt; an unclaimed or foreign-content code.tmp is kept untouched with bootstrap-uncertain, a claimed clone left by a kill is adopted exactly once, and no deterministic path is removed recursively. I1: the #800 freshness check runs before every effect including recovery effects; a mismatch appends setup wait request-plan-stale and does nothing further, evidence kept. I2: managedState classifies by layout (entry root and repo match the managed paths) and reads readiness from the marker and the committed activate receipt, so a missing bootstrap.json no longer admits an unfinished product; the launcher and trackerFor already call managedState and needed no change. I3: new packages/bus/src/request-host.mjs wires preparePlans and admitPlan in the real host from the organization document and revision, the booted tracker config and the staff the organization names; a missing tracker root or staff is a named setup wait (request-tracker-unavailable, request-staff-unavailable, request-organization-unavailable, request-organization-changed) and creates nothing; process.mjs now forwards trackers to startBroker. A normal boot with a tracker root and named staff reaches a bootstrap selection and bootstraps the project. I4: the Task 1 existing-project comparison was reversed and is fixed in localPlan with regressions in request-intake. RED/GREEN: each regression was failing on c55849806 and passes now; the regression set was mutation-checked by reverting each guard, with two exceptions recorded honestly: the executor-level metadataProblem mutant (c1-exec) still passes because git status is a second guard, and the i3 admitPlan unit test was not mutation-checked. Corrections: the committed project-file link test first failed at boot because the config loader refuses the link (test now commits the link after boot); the c2-claim mutant first survived because clone verification is a second guard (two unclaimed-code.tmp tests added); boot recovery rewrote a complete marker for a deleted reserved one, fixed by requiring the marker to prove ownership before every post-reserve effect; the i2 assertion on admission revision null was dropped because that Minor item is out of scope. First full bus run had one failure, model-final-completion 656 FIFO niceness (startNice 16 !== 19), unrelated to this change; the file passed 21/21 three times alone and the full rerun was 1477/1477. GREEN: brief trio 79/79, kept-green trio 49/49, bus 1477/1477 (was 1456), business 305/305 (was 304), project+seat 267/267, stack-release 225/225, zero skips. Limits: the selected tracker board is unverified until the first task is created; after a post-effect stale wait nothing resolves automatically, the request waits and each resume re-checks; a kill mid-clone leaves code.tmp kept and uncertain, never deleted; the prepareExisting/listProjects wiring is out of scope; admission revision stays null. Logs /mnt/storage/scratch/tmp/fix797/. Not executed on any running instance; local commit only, no push/merge/PR/deployment.
2026-11-04T02:51:19Z | Lead (Claude Opus 5.5, T3 c5c1e8ba) | #797 / Gitea #1839 lifecycle Task 2 landing | Task 2 c55849806 (Sonnet 5.5 xhigh): exact-add organization writer, journaled bootstrap steps, 3o->3p append-only migration, receipt-backed runtime admission. Review R1 (GPT-6.1 Sol): NEEDS FIXES, C1 seed write through a committed .mosaic link, C2 recursive code.tmp delete, I1 #800 freshness only at reservation, I2 missing marker admitted, I3 no host preparation/admission wiring, I4 Task 1 existing-project root/repo comparison reversed. Fix round 883003041 (Sonnet 5.5 xhigh): O_EXCL|O_NOFOLLOW publish, claimed code.tmp, per-effect fresh(), layout-based managedState, request-host.mjs wiring, corrected localPlan. Gates on 883003041: focused 79/79, kept-green 49/49, bus 1477/1477 (first run had one model-final-completion startNice flake, reviewer found no causal path), business 305/305, project+seat 267/267, stack-release 225/225, zero skips (/mnt/storage/scratch/tmp/fix797). Review R2: C1 C2 I3 I4 resolved; I1 (org write during intent step Git reads still lets a stale intent write), I2 (failed final marker rename or later marker removal leaves admission ready) and new N1 (preexisting <dataRoot>/working ancestor link sends the working repo outside dataRoot) open. Lead decision: land under the MVP one-round rule; all three need write access to dataRoot, a concurrent organization edit or an I/O fault, and none is reachable by a sandboxed worker. They form one hardening follow-up that must land before bootstrap is exercised on .53. Minors are tracker #801.
2026-11-04T04:06:34Z | GPT-6.1 Sol implementation sub-agent, t3/lifecycle-task-5a-create-recovery | #797 / Gitea #1839 lifecycle Task 5a before/after | Before: 6dd4f92b0 rejects operation keys and cannot recover a lost tracker create response. After: optional op reserves an immutable action.allowed tracker.create.prepared event, complete POST body/digest, target board and original PM/bot identity before any create-path network I/O; optional project_key and requirement_snapshot are opaque evidence and visible marker fields. Reusing an op cannot POST again, even after a complete negative scan or a restart before POST. Sync-only id-keyed full scans include closed tasks and require one marker, unchanged create fields, original board and creator; edited/missing/duplicate markers and partial/failed scans retain a deduplicated action.refused tracker.create.wait reconciliation receipt. Host-only recordRecoveredCreate validates the prepared receipt, body, creator and board bucket, atomically records the missing self snapshot/task.created once with original attribution, and has no socket route. Adapter exposes reconcileCreates and inspectProject, startup recovers; live PM retries resume labels/relations through read-before-write and current authority checks, assignment uses the existing separate verb. Legacy calls without new fields preserve their existing behavior; lifecycle callers must reuse a stable op for safe retries. Scope-only calls receive a generated op recorded in evidence. No migration, schema-pin edits, role/contract changes or runtime.mjs edits; packages/tasks/requirements.json and its consistency test unchanged. RED: new recovery suite 0/16 on parent; final regressions 0/21 on parent, expected invalid-request for op. Authority and forged-bucket checks also observed RED before their fixes. Corrections: event kind/subject guards required existing action event kinds instead of inventing task/ tracker kinds; restart fixtures needed historical launch cleanup before a successor claim; initial npm workspace invocation refused before tests and was replaced with per-package npm test. First full bus 1473/1477 exposed the added broker import outside the sandbox source closure, including three managed-launch failures; moved pure identity helpers into already-mounted broker.mjs, with parent project-runtime 3/3 and model-hire-ownership 18/18, then focused 42/42. Second bus 1476/1477 encountered unchanged row 280 sshd fixture cleanup ENOTEMPTY and hung; terminated only that run's owned host-run test and sshd, then waited for completion. Parent host-run was 25/26 with T27 failure (row 280 passed); local isolated host-run 26/26. Final GREEN: recovery 21/21, required verbs/adapter/tree/requirements 61/61, full tasks npm test 121/121, full bus npm test 1477/1477, zero failures/skips, diff check clean. Full logs and parent archive: /mnt/storage/scratch/tmp/CkjqAm/ (gate-recovery.log, gate-required.log, gate-tasks.log, gate-bus.log plus diagnostic history). Queue command refused this linked worktree, so no queue mutation; lead owns integration/queue. Task 5b catalogue validation, requirementsFor, built-in binding, task scope filtering and push product binding remain deferred as directed, pending Task 4. One local commit only; no push or live tracker/network/instance changes.
+2
View File
@@ -1997,3 +1997,5 @@ are never rewritten or removed; corrections are new entries.
2026-11-04 | Claude Sonnet 5.5 implementation sub-agent, t3/lifecycle-bootstrap-fix-round | #797 / Gitea #1839 lifecycle Task 2 fix round | C1 link-confined seed and marker writes, C2 receipt-backed code.tmp kept as bootstrap-uncertain when unproven, I1 freshness recheck before every effect, I2 layout-based managed classification, I3 host preparePlans/admitPlan wired (named setup waits), I4 existing-project comparison. Brief 79/79, kept-green 49/49, bus 1477/1477, business 305/305, project+seat 267/267, stack-release 225/225, zero skips; no schema change; local commit only, no push.
2026-11-04 | Lead (Claude Opus 5.5, T3 c5c1e8ba) | #797 lifecycle Task 2 review, fix round and landing | R1 needs fixes, fix 883003041, R2 left I1/I2/N1 residuals as a pre-.53 hardening follow-up; landed on refactor
2026-11-03 | GPT-6.1 Sol implementation sub-agent, t3/lifecycle-task-5a-create-recovery | #797 / Gitea #1839 lifecycle Task 5a | Optional journaled create operations, sync-only complete-scan adoption, host-only recovery with original attribution and PM follow-up resume; no migration. Recovery 21/21, required 61/61, tasks 121/121, bus 1477/1477, zero skips; diagnostics and parent reruns in /mnt/storage/scratch/tmp/CkjqAm/. Task 5b deferred, queue left to lead after linked-worktree refusal; one local commit only, no push or live changes.
+94
View File
@@ -231,6 +231,27 @@ const fail = (code) => {
throw new BusError(code);
};
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
// Lifecycle Task 5a: keep these pure helpers here because broker.mjs is also loaded by the
// sandbox client. A new host-only import would widen its mounted source closure.
const createJson = value => {
if (Array.isArray(value)) return `[${value.map(createJson).join(',')}]`;
if (object(value)) return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${createJson(value[k])}`).join(',')}}`;
return JSON.stringify(value);
};
export const createDigest = value => sha256(createJson(value));
export function createMarker({ op, request, project_key, requirement_snapshot }) {
return `Mosaic operation: ${op}; request: ${request}` +
(project_key === undefined ? '' : `; scope: ${project_key}`) +
(requirement_snapshot === undefined ? '' : `; snapshot: ${requirement_snapshot}`);
}
export function matchesCreate(prepared, fields, creator) {
const body = prepared.body;
return prepared.body_digest === createDigest(body) &&
fields.project_id === prepared.project && creator === prepared.creator &&
fields.title === body.title && fields.description === body.description &&
fields.due_date === (body.due_date ?? null) && fields.priority === (body.priority ?? 0);
}
// C2-06 (design 5.2, B7): the page store keeps a session's last 20 broker fetches, 200 KB of text each.
const PAGE_COUNT = 20;
const PAGE_BYTES = 200 * 1024;
@@ -4800,6 +4821,79 @@ export class Broker {
return this.#event(s, kind, body, subject);
});
}
// Host-only create journal. A prepared event is also the one-shot POST reservation. These
// methods are deliberately absent from request()/the socket vocabulary. No live cap is fabricated
// when startup recovers evidence for an ended session.
prepareTaskCreate(cap, prepared) {
const s = this.#session(cap);
this.#secretCheck(prepared);
return this.#store.transaction(() => {
this.#agent(s);
const role = this.#business(s.business).roles[s.role];
if (role?.definition !== 'pm' || role.tracker?.botId !== prepared.creator) fail('field-writer');
if (typeof prepared.op !== 'string' || !prepared.op || !Number.isSafeInteger(prepared.project) || prepared.project < 1 ||
prepared.body_digest !== createDigest(prepared.body) || !prepared.body.description.endsWith(createMarker(prepared))) fail('invalid-request');
const old = this.taskCreateReceipts(s.business).find(r => r.body.op === prepared.op);
if (old) {
if (old.actor_role !== s.role || createDigest(old.body) !== createDigest(prepared)) fail('task-create-conflict');
this.#checkAuthority(s, 'task.create', { decision: prepared.decision, target: null });
return { receipt: old, fresh: false };
}
this.#consumeAuthority(s, 'task.create', { decision: prepared.decision, target: null });
const id = this.#event(s, 'action.allowed', { operation: 'tracker.create.prepared', ...prepared });
return { receipt: { id, business: s.business, actor_role: s.role, actor_run: s.run, body: prepared }, fresh: true };
});
}
taskCreateReceipts(business) {
this.#business(business);
return this.#store.all("SELECT id,business,actor_role,actor_run,body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='tracker.create.prepared' ORDER BY seq", business)
.map(r => { const { operation, ...body } = JSON.parse(r.body); return { ...r, body }; });
}
#preparedCreate(receipt) {
if (!object(receipt) || !id(receipt.id)) fail('invalid-request');
const stored = this.taskCreateReceipts(receipt.business).find(r => r.id === receipt.id);
if (!stored || createDigest(stored) !== createDigest(receipt) || stored.body.body_digest !== createDigest(stored.body.body)) fail('task-create-conflict');
return stored;
}
taskCreateResult(receipt) {
const r = this.#preparedCreate(receipt);
return this.#store.get("SELECT subject FROM events WHERE business=? AND kind='task.created' AND json_extract(body,'$.create_receipt')=? ORDER BY seq LIMIT 1", r.business, r.id)?.subject ?? null;
}
recordCreateWait({ receipt, code }) {
return this.#store.transaction(() => {
const r = this.#preparedCreate(receipt);
if (code !== 'task-create-uncertain') fail('invalid-request');
const last = this.#store.get("SELECT body FROM events WHERE business=? AND kind='action.refused' AND json_extract(body,'$.operation')='tracker.create.wait' AND json_extract(body,'$.receipt')=? ORDER BY seq DESC LIMIT 1", r.business, r.id);
if (last) return;
this.#event({ business: r.business, role: r.actor_role, run: r.actor_run }, 'action.refused', {
operation: 'tracker.create.wait', receipt: r.id, op: r.body.op, request: r.body.request, code, wait: 'reconciliation',
});
});
}
recordRecoveredCreate({ receipt, task_ref, snapshot }) {
this.#secretCheck({ receipt, task_ref, snapshot });
return this.#store.transaction(() => {
const r = this.#preparedCreate(receipt), p = r.body;
if (!taskRef(task_ref) || !task_ref.startsWith(`vikunja:${p.project}/`) || snapshot?.task_ref !== task_ref ||
!object(snapshot.fields) || !Object.values(p.view.ids).includes(snapshot.fields.bucket) ||
snapshot.digest !== createDigest(snapshot.fields) || !matchesCreate(p, snapshot.fields, snapshot.creator)) fail('task-create-conflict');
const old = this.taskCreateResult(r);
if (old) {
if (old !== task_ref) fail('task-create-conflict');
return { task_ref: old };
}
const s = { business: r.business, role: r.actor_role, run: r.actor_run };
const { creator, ...kept } = snapshot;
this.#snapshot(s, kept);
this.#event(s, 'task.created', {
request: p.request, requirement: p.requirement, op: p.op, create_receipt: r.id, create_body_digest: p.body_digest,
...(p.project_key === undefined ? {} : { project_key: p.project_key }),
...(p.requirement_snapshot === undefined ? {} : { requirement_snapshot: p.requirement_snapshot }),
}, task_ref);
return { task_ref };
});
}
// Trusted S3 adapter only. With a cap it records a role's own completed write: the external call
// already happened, so this records and does not re-authorize. Without one it records sync reads.
recordTask({ cap = null, business, snapshots = [], events = [], commentCursors = [] }) {
+54 -3
View File
@@ -147,8 +147,9 @@ loads it for its `requirements` read. Neither starts on a bad file.
A write that answers 5xx or doesn't answer is never retried. The adapter
reads the task again. If the write took effect, it continues. If not,
or the re-read fails too, the verb refuses with `write-uncertain`. A
create can't be checked that way, so an uncertain create is reported as
`write-uncertain` and a person checks the board.
legacy create without an operation key can't be checked that way, so it reports
`write-uncertain` and a person checks the board. Creates with `op` use the
durable recovery protocol below.
On success the self snapshot holds the fields the verb set, not what the
final read saw. If a person edits the task between the last write and
@@ -170,7 +171,7 @@ them, so it isn't passed on:
| Verb | Who | Arguments | Event |
|---|---|---|---|
| `task.create` | pm | `title`, `request`, `requirement`; optional `project`, `description`, `due_date`, `priority`, `labels`, `relations` | `task.created` |
| `task.create` | pm | `title`, `request`, `requirement`; optional `project`, `description`, `due_date`, `priority`, `labels`, `relations`, `op`, `project_key`, `requirement_snapshot` | `task.created` |
| `task.assign` | pm | `task_ref`, `role` | `task.assigned` |
| `task.reassign` | pm | `task_ref`, `role` | `task.assigned` |
| `task.schedule` | pm | `task_ref`; one or more of `due_date`, `labels.{add,remove}`, `relations.{add,remove}` | snapshot only |
@@ -235,6 +236,54 @@ Details that aren't obvious from the table:
Verbs for one business run one at a time, in the same queue as the poll,
so a poll never reads between two writes of a verb.
## Recoverable creates (lifecycle Task 5a)
A lifecycle caller supplies a stable `op` with `task.create` and reuses it for
all retries of that exact request and body. Existing calls without the new
fields keep their previous behavior. Optional `project_key` and
`requirement_snapshot` are opaque strings carried into evidence; Task 5b will
validate their catalogue binding. If either is supplied without `op`, the
adapter generates a key, recorded in `task.created` evidence. Supply your own
key to retry safely after a lost response.
The adapter adds this visible suffix to the description, omitting absent fields:
```text
Mosaic operation: <op>; request: <request>; scope: <project_key>; snapshot: <snapshot>
```
Before any create-path network I/O, the broker atomically checks authority,
consumes any approval once and appends an `action.allowed` event with
`operation: tracker.create.prepared`. It stores the original role/run, bot,
request, requirement, complete POST body and SHA-256 digest, target project and
installed board, labels and relations. The prepared event is a one-shot POST
reservation. A repeated `op` with a changed body or target refuses with
`task-create-conflict`. Even a restart before the POST leaves the operation
reserved; a negative scan cannot prove that no create happened.
An uncertain answer records an `action.refused` reconciliation wait with
`operation: tracker.create.wait` and code `task-create-uncertain`. Recovery walks
the target project's full task list by increasing ID, including completed tasks.
Only one matching marker, the original creator bot, unchanged create fields and
the original installed board permit adoption. Missing, edited, duplicate or
foreign markers, changed fields and incomplete/failed scans stay uncertain.
There is no replacement POST. The wait is deduplicated; a later `task.created`
receipt resolves it without rewriting history.
The adapter's host-only `reconcileCreates(business)` runs at startup and can be
called for recovery. It only reads the tracker with sync authority. The
host-only `inspectProject(business, project)` exposes the same installation
checks startup uses. `Broker.recordRecoveredCreate({receipt, task_ref,
snapshot})` verifies the stored preparation, body digest, target and creator,
then atomically writes the missing self snapshot and `task.created` event once
under the original role/run. These are host methods, never socket verbs.
A live PM retry resumes labels and relations using the existing compare-read
and uncertain-write checks, rechecking current authority before effects. Startup
never writes these as sync. Assignment remains the existing separate
`task.assign` operation on the adopted reference. A closed task can be adopted;
recovery does not reopen it or add follow-up writes to it.
## Sync
Every `pollSeconds` the adapter reads the whole board, then the task list
@@ -385,6 +434,8 @@ on a live read with the sync key, and a switch is a follow-up row.
| `tracker-invalid` | Vikunja answered 400 or 422 |
| `tracker-rate-limited` | 429 |
| `tracker-unavailable` | 5xx or no answer on a read |
| `task-create-uncertain` | reserved operation requires reconciliation; never authorizes another POST |
| `task-create-conflict` | an operation key or recovery receipt has a different identity, body or target |
| `write-uncertain` | 5xx or no answer on a write, not settled by a re-read |
| `tracker-shape`, `tracker-paging`, `tracker-unexpected` | Vikunja sent something the probes didn't show |
| `task-moved`, `task-placement`, `task-project` | the task is in another project, or on no bucket |
+12 -1
View File
@@ -1,7 +1,8 @@
import { lstatSync } from 'node:fs';
import { BusError } from '../../bus/src/broker.mjs';
import { client, apiRoot } from './vikunja.mjs';
import { startup } from './startup.mjs';
import { reconcileCreate } from './create-recovery.mjs';
import { startup, inspect } from './startup.mjs';
import { tick, reconcile } from './sync.mjs';
import { verbs } from './verbs.mjs';
// S3's adapter for startBroker({tasks}). `trackers` is the host's plain-data boot config:
@@ -146,10 +147,17 @@ export function tasksAdapter({
if (stamp(f.file) !== f.stamp) tell('credential.changed', `${ctx.business}/${instance}`, null);
if (events.length) broker.recordTask({ business: ctx.business, events });
}
async function reconcileCreates(ctx) {
const results = [];
for (const receipt of broker.taskCreateReceipts(ctx.business))
results.push(await reconcileCreate({ ctx, receipt }));
return results;
}
async function start(ctx) {
try {
await startup(ctx);
await reconcile(ctx);
await reconcileCreates(ctx);
ctx.state = 'ready';
ctx.refused = null;
if (ctx.untested) log(`tasks ${ctx.business} startup untested-version`);
@@ -174,6 +182,9 @@ export function tasksAdapter({
if (ctx.state !== 'ready') throw new BusError(ctx.state === 'starting' ? 'tracker-starting' : ctx.refused);
return serial(ctx, () => ctx.handle(cap, verb, args));
},
// Trusted host surface only, never a socket verb. Startup recovery only reads the tracker.
reconcileCreates: (business) => serial(contexts.get(business), () => reconcileCreates(contexts.get(business))),
inspectProject: (business, project) => serial(contexts.get(business), () => inspect(contexts.get(business), project)),
// For tests and the host's status verb. Each returns once the operation has run.
start: (business) => serial(contexts.get(business), () => start(contexts.get(business))),
tick: (business) => serial(contexts.get(business), () => tick(contexts.get(business))),
+43
View File
@@ -0,0 +1,43 @@
import { board, walk, normal } from './sync.mjs';
import { checkTask, ref, taskFields, digest } from './digest.mjs';
import { outcome } from './vikunja.mjs';
import { createMarker, matchesCreate, createDigest } from '../../bus/src/broker.mjs';
// Host/adapter only. All tracker I/O here uses the read-only sync authority. A complete negative
// scan is evidence of uncertainty, never permission to issue a replacement create.
export async function reconcileCreate({ ctx, receipt }) {
const p = receipt.body;
const wait = () => {
ctx.broker.recordCreateWait({ receipt, code: 'task-create-uncertain' });
return { state: 'uncertain', code: 'task-create-uncertain' };
};
const committed = ctx.broker.taskCreateResult(receipt);
if (committed) return { state: 'committed', task_ref: committed };
try {
const view = ctx.tree?.get(p.project);
if (!view || createDigest(view) !== createDigest(p.view)) return wait();
// An id-keyed complete walk includes done tasks and cannot accept a truncated page set.
const tasks = await walk(ctx, '', {}, p.project);
const marker = createMarker(p);
// Count all occurrences of this operation, including edited suffixes and foreign creators.
// A duplicate with changed fields must not make an otherwise valid candidate unique.
const operation = `Mosaic operation: ${p.op}`;
const hits = tasks.filter(t => (t.description ?? '').split('\n').some(line => line === operation || line.startsWith(operation + ';')));
if (hits.length !== 1 || !hits[0].description.endsWith(marker)) return wait();
const r = await ctx.sync('GET', `/tasks/${hits[0].id}`);
if (outcome(r) !== 'ok') return wait();
const task = r.json;
checkTask(task);
if (task.id !== hits[0].id || task.project_id !== p.project) return wait();
const bucket = task.done ? view.done : (await board(ctx, p.project, view)).get(task.id)?.bucket;
const fields = taskFields(task, bucket);
if (!matchesCreate(p, fields, task.created_by?.id)) return wait();
const task_ref = ref(p.project, task.id);
ctx.broker.recordRecoveredCreate({ receipt, task_ref, snapshot: {
task_ref, updated: normal(task.updated), etag: r.etag ?? null, digest: digest(fields), fields, creator: task.created_by.id,
} });
return { state: 'committed', task_ref };
} catch {
return wait();
}
}
+1 -1
View File
@@ -64,7 +64,7 @@ export async function startup(ctx) {
}
// Install checks: one manual kanban view, the five buckets once each, done and default wired.
// Returns the view, or refuses tracker-install. A failed read refuses tracker-unavailable.
async function inspect(ctx, project) {
export async function inspect(ctx, project) {
const views = await all(ctx.syncCall, null, `/projects/${project}/views`, {}, ctx.per);
if (!views.ok) refuse('tracker-unavailable');
const kanban = views.items.filter((x) => x?.view_kind === 'kanban');
+74 -2
View File
@@ -1,4 +1,6 @@
import { BusError } from '../../bus/src/broker.mjs';
import { randomUUID } from 'node:crypto';
import { reconcileCreate } from './create-recovery.mjs';
import { BusError, createMarker, createDigest } from '../../bus/src/broker.mjs';
import { outcome, refusal } from './vikunja.mjs';
import { ref, parseRef, checkTask, taskFields, digest } from './digest.mjs';
import { board, normal } from './sync.mjs';
@@ -200,7 +202,7 @@ export function verbs(ctx) {
if (args?.expect !== undefined && !/^[0-9a-f]{64}$/.test(args.expect)) fail('invalid-request');
switch (verb) {
case 'task.create': {
keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'relations', 'request', 'requirement', 'decision', 'project'], ['title', 'request', 'requirement']);
keys(args, ['title', 'description', 'due_date', 'priority', 'labels', 'relations', 'request', 'requirement', 'decision', 'project', 'op', 'project_key', 'requirement_snapshot'], ['title', 'request', 'requirement']);
// Mos's ruling: an optional project, which must be an installed project of the tree. Without
// it the task goes to the root.
const project = args.project === undefined ? ctx.project : args.project;
@@ -221,6 +223,17 @@ export function verbs(ctx) {
plan.requirement = text(args.requirement, 64);
if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request');
if (!requirements.has(plan.requirement)) fail('unknown-requirement');
for (const field of ['op', 'project_key', 'requirement_snapshot'])
if (args[field] !== undefined) {
plan[field] = text(args[field], 256, 1);
if (/[;\r\n]/.test(plan[field])) fail('invalid-request');
}
// Existing unscoped calls retain their established body and evidence. The new lifecycle
// opts into recovery with an op; scope-only callers get a generated one.
if (plan.op !== undefined || plan.project_key !== undefined || plan.requirement_snapshot !== undefined) {
plan.op ??= randomUUID();
plan.body.description = (body.description ? body.description + '\n\n' : '') + createMarker(plan);
}
return plan;
}
case 'task.assign':
@@ -386,8 +399,67 @@ export function verbs(ctx) {
fail('unknown-verb');
}
}
async function recoverableCreate(cap, me, plan) {
// Reservation is durable before even a relation's compare-read. A kill anywhere beyond this
// point can only reconcile, including a kill before the POST actually left the process.
const prepared = {
op: plan.op, request: plan.request, requirement: plan.requirement, decision: plan.decision,
project: plan.at.project, view: plan.at.view, creator: ctx.roles[me.role].botId,
body: plan.body, body_digest: createDigest(plan.body), labels: plan.labels, relations: plan.relations,
...(plan.project_key === undefined ? {} : { project_key: plan.project_key }),
...(plan.requirement_snapshot === undefined ? {} : { requirement_snapshot: plan.requirement_snapshot }),
};
const { receipt, fresh } = ctx.broker.prepareTaskCreate(cap, prepared);
let taskRef;
if (fresh) {
await located(plan.others);
ctx.broker.prepareTaskCreate(cap, prepared);
const r = await ctx.as(me, 'POST', `/projects/${plan.at.project}/tasks`, { body: plan.body });
if (outcome(r) !== 'ok' || ![200, 201].includes(r.status)) {
ctx.broker.recordCreateWait({ receipt, code: 'task-create-uncertain' });
fail('task-create-uncertain');
}
try {
checkTask(r.json);
taskRef = ref(plan.at.project, r.json.id);
const fields = taskFields(r.json, plan.at.view.todo);
ctx.broker.recordRecoveredCreate({ receipt, task_ref: taskRef, snapshot: {
task_ref: taskRef, fields, digest: digest(fields), updated: normal(r.json.updated), etag: null, creator: r.json.created_by?.id,
} });
} catch {
ctx.broker.recordCreateWait({ receipt, code: 'task-create-uncertain' });
fail('task-create-uncertain');
}
} else {
const result = await reconcileCreate({ ctx, receipt });
if (result.state !== 'committed') fail(result.code);
taskRef = result.task_ref;
}
const at = target(taskRef);
let after = await read(at);
// The read side may recover a closed task; only an acting PM can finish writes on an open one.
if (!after.task.done) {
await located(plan.others);
for (const l of plan.labels) if (!after.fields.labels.includes(l)) {
ctx.broker.prepareTaskCreate(cap, prepared);
await step(me, 'POST', `/tasks/${at.id}/labels`, { label_id: l }, at, a => a.fields.labels.includes(l));
after = await read(at);
}
for (const x of plan.relations) if (!relatedTo(after.task, x.kind, x.other)) {
ctx.broker.prepareTaskCreate(cap, prepared);
await step(me, 'POST', `/tasks/${at.id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at,
a => relatedTo(a.task, x.kind, x.other));
after = await read(at);
}
}
ctx.broker.recordTask({ cap, business: ctx.business, snapshots: [{
task_ref: taskRef, fields: after.fields, digest: after.digest, updated: after.updated, etag: after.etag,
}] });
return { task_ref: taskRef, digest: after.digest, updated: after.updated };
}
async function create(cap, me, plan) {
if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found');
if (plan.op !== undefined) return recoverableCreate(cap, me, plan);
await located(plan.others);
ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null });
// No re-read can find a create whose answer was lost, so an uncertain create is reported as such.
@@ -0,0 +1,285 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { world } from './world.mjs';
import { FakeVikunja } from '../src/fake.mjs';
import { digest } from '../src/digest.mjs';
const journal = (w, operation) => w.events(operation.endsWith('.wait') ? 'action.refused' : 'action.allowed').filter(e => e.body.operation === operation);
const resumePm = w => {
w.broker.recoverLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' });
const cap = w.broker.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-resumed', harness: 'pi' });
w.broker.request(cap, { verb: 'role.claim' });
return (w.caps.pm = cap);
};
const uncertain = e => e.code === 'task-create-uncertain';
async function fixture(t, edit = {}) {
const w = await world(t, edit);
await w.adapter.start('demo');
const pm = w.agent('pm');
const args = { title: 'recover me', description: 'original body', request: w.instruction(), requirement: 'REQ-TASK-1', op: 'create-5a' };
const create = (extra = {}, cap = pm) => w.call(cap, 'task.create', { ...args, ...extra });
const posts = () => w.fake.requests.filter(r => r.method === 'POST' && /^\/projects\/\d+\/tasks$/.test(r.path)).length;
return { w, pm, args, create, posts };
}
test('lost response after tracker create is adopted once with original attribution and optional scope', async t => {
const { w, args, create, posts } = await fixture(t);
w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true });
const scope = { project_key: 'demo/product', requirement_snapshot: 'snapshot-v7' };
await assert.rejects(create(scope), uncertain);
assert.equal(posts(), 1);
assert.equal(w.events('task.created').length, 0);
assert.equal(w.fake.task(1).description, `original body\n\nMosaic operation: create-5a; request: ${args.request}; scope: demo/product; snapshot: snapshot-v7`);
await w.stop();
await w.restart();
const [created] = w.events('task.created');
assert.equal(created.subject, `vikunja:${w.project}/1`);
assert.equal(created.actor_role, 'pm');
assert.equal(created.actor_run, 'pm-run');
assert.equal(created.body.project_key, 'demo/product');
assert.equal(created.body.requirement_snapshot, 'snapshot-v7');
assert.equal(created.body.op, args.op);
await w.adapter.reconcileCreates('demo');
const made = await create(scope, resumePm(w));
assert.equal(made.task_ref, created.subject);
assert.equal(w.events('task.created').length, 1);
assert.equal(posts(), 1);
});
test('restart before create receipt survives a real Store close and reopen', async t => {
const { w, create, posts } = await fixture(t);
w.store.run("CREATE TRIGGER crash_receipt BEFORE INSERT ON events WHEN NEW.kind='task.created' BEGIN SELECT RAISE(ABORT,'crash before receipt'); END");
await assert.rejects(create());
assert.equal(posts(), 1);
assert.equal(journal(w, 'tracker.create.prepared').length, 1);
assert.equal(w.events('task.created').length, 0);
w.store.run('DROP TRIGGER crash_receipt');
await w.stop();
await w.restart();
assert.equal(w.events('task.created').length, 1);
assert.equal(posts(), 1);
});
test('duplicate PM calls share one create and changed operation bodies are refused', async t => {
const { w, create, posts } = await fixture(t);
const [a, b] = await Promise.all([create(), create()]);
assert.equal(a.task_ref, b.task_ref);
assert.equal(posts(), 1);
assert.equal(w.events('task.created').length, 1);
await assert.rejects(create({ title: 'changed' }), e => e.code === 'task-create-conflict');
await assert.rejects(create({ labels: [w.label] }), e => e.code === 'task-create-conflict');
assert.equal(posts(), 1);
});
test('label and relation follow-up failures resume by read-before-write without another create', async t => {
const { w, create, posts } = await fixture(t);
const other = await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'related' });
assert.equal(other.status, 201);
const extras = { labels: [w.label], relations: [{ kind: 'blocking', task_ref: `vikunja:${w.project}/1` }] };
w.fake.fault('POST', /^\/tasks\/2\/labels$/, 500);
await assert.rejects(create(extras));
assert.equal(w.events('task.created').length, 1);
await w.stop();
await w.restart();
w.fake.fault('POST', /^\/tasks\/2\/relations$/, 500);
const pm = resumePm(w);
await assert.rejects(create(extras, pm));
const labels = () => w.fake.requests.filter(r => r.method === 'POST' && r.path === '/tasks/2/labels').length;
assert.equal(labels(), 2);
const r = await create(extras, pm);
assert.equal(r.task_ref, `vikunja:${w.project}/2`);
assert.equal(labels(), 2);
assert.deepEqual(w.fake.task(2).relations, [{ kind: 'blocking', other: 1 }]);
assert.equal(posts(), 2); // one UI task and one Mosaic task
assert.equal(w.events('task.created').length, 1);
});
test('assignment failure after recovery can resume on the adopted task', async t => {
const { w, create, posts } = await fixture(t);
w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 502, { apply: true });
await assert.rejects(create(), uncertain);
await w.adapter.reconcileCreates('demo');
const { task_ref } = await create();
w.fake.fault('POST', /^\/tasks\/1\/assignees$/, 500);
await assert.rejects(w.call(w.caps.pm, 'task.assign', { task_ref, role: 'coder' }));
await w.stop();
await w.restart();
const same = await create({}, resumePm(w));
await w.call(w.caps.pm, 'task.assign', { task_ref: same.task_ref, role: 'coder' });
assert.deepEqual(w.fake.task(1).assignees, [w.bots.coder]);
assert.equal(posts(), 1);
});
test('closed matching task is recovered across all pages including completed tasks', async t => {
const { w, create, posts } = await fixture(t);
for (let i = 0; i < 51; i++) await w.ui('POST', `/projects/${w.project}/tasks`, { title: `padding ${i}` });
w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true });
await assert.rejects(create(), uncertain);
await w.ui('PATCH', '/tasks/52', { done: true });
await w.stop();
await w.restart();
assert.equal(w.events('task.created')[0].subject, `vikunja:${w.project}/52`);
assert.equal(w.snapshots(`vikunja:${w.project}/52`).at(-1).fields.done, true);
assert.equal(posts(), 52);
});
for (const change of ['edited marker', 'changed body', 'wrong creator', 'moved board', 'duplicate marker', 'missing marker', 'complete scan failure', 'partial scan failure']) {
test(`${change} stays uncertain with a durable reconciliation wait and never posts again`, async t => {
const { w, create, posts } = await fixture(t);
w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true });
await assert.rejects(create(), uncertain);
if (change === 'edited marker') await w.ui('PATCH', '/tasks/1', { description: w.fake.task(1).description.replace('create-5a', 'edited') });
if (change === 'changed body') await w.ui('PATCH', '/tasks/1', { title: 'edited title' });
if (change === 'wrong creator') w.fake.task(1).by = w.owner;
if (change === 'moved board') {
const p = w.fake.project('Elsewhere', w.owner);
w.fake.moveToProject(1, p);
}
if (change === 'duplicate marker') await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'recover me', description: w.fake.task(1).description });
if (change === 'missing marker') await w.ui('PATCH', '/tasks/1', { description: 'removed' });
if (change === 'partial scan failure') {
for (let i = 0; i < 50; i++) await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'padding' });
}
const before = posts();
if (change.includes('scan failure')) {
// Recovery's scan is id-keyed. Fail every scan, including after the first page.
if (change === 'partial scan failure') {
const use = w.credentials.use.bind(w.credentials);
w.credentials.use = (instance, service, fn) => use(instance, service, async token => {
const result = await fn(token);
if (instance === 'demo/@sync' && result.json?.items?.length === 50 && result.json.items[0]?.title === 'recover me')
w.fake.fault('GET', /^\/projects\/\d+\/tasks$/, 500);
return result;
});
} else w.fake.fault('GET', /^\/projects\/\d+\/tasks$/, 500);
}
const results = await w.adapter.reconcileCreates('demo');
assert.equal(results[0].state, 'uncertain');
if (change === 'complete scan failure') w.fake.fault('GET', /^\/projects\/\d+\/tasks$/, 500);
await assert.rejects(create(), uncertain);
assert.equal(w.events('task.created').length, 0);
assert.equal(posts(), before);
const waits = journal(w, 'tracker.create.wait');
assert.ok(waits.length > 0);
assert.equal(waits.at(-1).body.code, 'task-create-uncertain');
});
}
test('negative scan after a prepared create never authorizes a second POST', async t => {
const { w, create, posts } = await fixture(t);
w.fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0);
await assert.rejects(create(), uncertain);
await w.stop();
await w.restart();
await assert.rejects(create({}, resumePm(w)), uncertain);
assert.equal(posts(), 1);
assert.equal(w.fake.task(1), undefined);
});
test('host recovery verifies prepared identity body and target and has no socket verb', async t => {
const { w, pm, create } = await fixture(t);
const made = await create();
const [receipt] = w.broker.taskCreateReceipts('demo');
const snapshot = { ...w.snapshots(made.task_ref).at(-1), creator: w.bots.pm };
const kept = Object.fromEntries(['task_ref', 'updated', 'etag', 'digest', 'fields', 'creator'].map(k => [k, snapshot[k]]));
const recover = (r = receipt, s = kept, task_ref = made.task_ref) => w.broker.recordRecoveredCreate({ receipt: r, task_ref, snapshot: s });
assert.doesNotThrow(() => recover());
assert.equal(w.events('task.created').length, 1);
assert.throws(() => recover({ ...receipt, actor_run: 'invented' }));
assert.throws(() => recover(receipt, { ...kept, creator: w.owner }));
assert.throws(() => recover(receipt, { ...kept, fields: { ...kept.fields, title: 'changed' } }));
assert.throws(() => recover(receipt, kept, 'vikunja:999/1'));
for (const change of [{ title: 'changed' }, { description: 'changed' }, { priority: 5 }, { due_date: '2026-12-01T00:00:00.000Z' }, { bucket: 999 }]) {
const fields = { ...kept.fields, ...change };
assert.throws(() => recover(receipt, { ...kept, fields, digest: digest(fields) }));
}
assert.throws(() => recover({ ...receipt, body: { ...receipt.body, body_digest: '0'.repeat(64) } }));
assert.throws(() => w.broker.request(pm, { verb: 'recordRecoveredCreate', args: {} }), e => e.code === 'unknown-verb');
const inspection = await w.adapter.inspectProject('demo', w.project);
assert.equal(inspection.id, w.installed.view);
});
test('prepared body digest and target are durable before the first network read or POST', async t => {
const fake = new FakeVikunja();
let observe = () => {};
const { w, args, create, posts } = await fixture(t, { fake, fetch: (url, options) => {
observe(url, options);
return fake.fetch(url, options);
} });
let reads = 0;
const other = await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'other' });
const extras = { relations: [{ kind: 'blocking', task_ref: `vikunja:${w.project}/${other.json.id}` }] };
observe = (url, options) => {
const [event] = journal(w, 'tracker.create.prepared');
assert.ok(event, 'reservation precedes every create-path network effect');
assert.equal(event.actor_run, 'pm-run');
assert.equal(event.body.project, w.project);
assert.match(event.body.body_digest, /^[a-f0-9]{64}$/);
assert.equal(event.body.body.title, args.title);
if (options.method === 'GET') reads++;
};
await create(extras);
assert.ok(reads > 0);
assert.equal(posts(), 2);
});
test('restart after reservation but before POST only reconciles and never creates', async t => {
const { w, create, posts } = await fixture(t);
const other = await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'other' });
const extras = { relations: [{ kind: 'blocking', task_ref: `vikunja:${w.project}/${other.json.id}` }] };
w.fake.fault('GET', /^\/tasks\/1$/, 500);
await assert.rejects(create(extras));
assert.equal(journal(w, 'tracker.create.prepared').length, 1);
assert.equal(posts(), 1);
await w.stop();
await w.restart();
await assert.rejects(create(extras, resumePm(w)), uncertain);
assert.equal(posts(), 1);
});
test('lost follow-up responses use existing reads to avoid duplicate label and relation writes', async t => {
const { w, create, posts } = await fixture(t);
await w.ui('POST', `/projects/${w.project}/tasks`, { title: 'other' });
const extras = { labels: [w.label], relations: [{ kind: 'blocking', task_ref: `vikunja:${w.project}/1` }] };
w.fake.fault('POST', /^\/tasks\/2\/labels$/, 0, { apply: true });
w.fake.fault('POST', /^\/tasks\/2\/relations$/, 0, { apply: true });
await create(extras);
await create(extras);
assert.equal(w.fake.requests.filter(r => r.method === 'POST' && r.path === '/tasks/2/labels').length, 1);
assert.equal(w.fake.requests.filter(r => r.method === 'POST' && r.path === '/tasks/2/relations').length, 1);
assert.equal(posts(), 2);
});
test('recovery works after the original PM session has ended and only reads as sync', async t => {
const fake = new FakeVikunja();
const requests = [];
const { w, create, posts } = await fixture(t, { fake, fetch: (url, options) => {
requests.push({ url, method: options.method, authority: options.headers.Authorization });
return fake.fetch(url, options);
} });
fake.fault('POST', /^\/projects\/\d+\/tasks$/, 0, { apply: true });
await assert.rejects(create(), uncertain);
w.broker.endLaunch({ business: 'demo', run: 'pm-run', reason: 'exited', exitCode: 1 });
const before = requests.length;
await w.adapter.reconcileCreates('demo');
const recovery = requests.slice(before);
assert.ok(recovery.length > 0);
assert.ok(recovery.every(r => r.method === 'GET' && r.authority === `Bearer ${w.tokens.sync}`));
assert.equal(w.events('task.created')[0].actor_run, 'pm-run');
assert.equal(posts(), 1);
});
test('a PM retry rechecks current task.create authority before resuming follow-up writes', async t => {
const { w, create } = await fixture(t);
const extras = { labels: [w.label] };
w.fake.fault('POST', /^\/tasks\/1\/labels$/, 500);
await assert.rejects(create(extras));
w.businesses.demo.roles.pm.authority.withinRole = w.businesses.demo.roles.pm.authority.withinRole.filter(a => a !== 'task.create');
w.broker.reloadBusiness('demo', w.businesses.demo);
const writes = () => w.fake.requests.filter(r => r.method === 'POST').length;
const before = writes();
await assert.rejects(create(extras), e => e.code === 'decision-required');
assert.equal(writes(), before);
});