feat(tasks): recover an uncertain tracker create without a second post (#1839)

This commit is contained in:
jason.woltje committed 2026-11-04 10:33:10 -06:00
1 parent 6f41eddb20
commit 1031686140
9 files changed
+566 -7

No files matched your search

+1
View File
@@ -6011,3 +6011,4 @@ Owner directed static-only fallback after actual nativepriority drift: no new pr
2026-11-03 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 lifecycle Task 7a | Added a bounded reviewed-artifact reader for committed regular blobs in trusted bare working repositories. Uses the existing bounded Git reader and safe environment, literal paths, disabled replacement refs, full commit IDs and a 1 MiB ceiling; rejects non-regular entries and checks size before reading bytes. RED: missing reader module; GREEN: artifact 9/9 and seat 232/232, zero skips; logs /mnt/storage/scratch/tmp/0G9g1p/. Queue next refused linked-worktree use; controller owns queue and integration. Local commit only; no delivery, broker, Console, push or deployment. 2026-11-03 | GPT-6.1 Terra implementation sub-agent | #797 / Gitea #1839 lifecycle Task 7a | Added a bounded reviewed-artifact reader for committed regular blobs in trusted bare working repositories. Uses the existing bounded Git reader and safe environment, literal paths, disabled replacement refs, full commit IDs and a 1 MiB ceiling; rejects non-regular entries and checks size before reading bytes. RED: missing reader module; GREEN: artifact 9/9 and seat 232/232, zero skips; logs /mnt/storage/scratch/tmp/0G9g1p/. Queue next refused linked-worktree use; controller owns queue and integration. Local commit only; no delivery, broker, Console, push or deployment.
2026-11-04T03:48:47Z | GPT-6.1 Sol implementation sub-agent, t3/pm-intake-tools | #797 / Gitea #1839 lifecycle Task 6a before/after | Assigned base 6dd4f92b0; harness had no intake tools and staff.request required a task. Added intake_open/request.open and intake_propose/request.propose only for PM holding message.send, plus intake_status/request.get and intake_projects/request.projects in PM and CTO pre-task manifests, independent of tracker/checkouts. Existing broker authority is unchanged: its request reads still refuse CTO (request-access-refused); tested and retained rather than widening authority. intake_question omitted because no backing request.question verb exists. staff.request/request_staff takes exactly one of request or task_ref with roles/note/decision unchanged, PM-only message.send and chief routing preserved; original request provenance/reachability uses the existing host check without a fabricated task or opened journal. Exported the existing runner toolServer for acceptance tests against production dispatch, including MCP; no new imports or sandbox/process mounts needed (import closure 1/1). PM duty prose and the generated PM staffing prompt describe pre-task intake and staffing. New tests cover actual PM/CTO/coder/reviewer/assistant bundles for pi and claude-code, assistant -> chief -> PM relays, exact user-mode decision with null task/requirement, duplicate open, wrong roles, invalid/unreachable input, exclusive staff references and assistant coordination restrictions. RED on assigned parent: 6 pass/12 fail (dispatch not exported); after exporting only the existing dispatch seam 7 pass/11 fail for missing tools/staff intake. First GREEN attempt 17/18 exposed a test fixture treating message.receive's array as an object, corrected without weakening assertions. Existing required tests first 53/55 and full harness first 222/223 failed only outdated manifest/schema/prompt expectations introduced by this slice; original parent reruns required 55/55 and bundle 22/22 passed. A further generated staffing prompt RED 21/22 proved the stale task-only instruction before updating it. Final gates: request-tools 18/18, required command 55/55, full harness 223/223, bus 1477/1477, business 305/305, sandbox import closure 1/1, all zero failures/skips/cancellations. Full logs, baseline extraction and red evidence: /mnt/storage/scratch/tmp/JHTDYm/; every run used that TMPDIR with pipefail. Local commit only; no push, no role JSON/contract/migration/schema/config/credential changes, no runtime/bootstrap execution, no continuation/PRD/advance/recovery work. Controller owns review, integration and queue/tracker coordination; linked-worktree queue command refused as designed. 2026-11-04T03:48:47Z | GPT-6.1 Sol implementation sub-agent, t3/pm-intake-tools | #797 / Gitea #1839 lifecycle Task 6a before/after | Assigned base 6dd4f92b0; harness had no intake tools and staff.request required a task. Added intake_open/request.open and intake_propose/request.propose only for PM holding message.send, plus intake_status/request.get and intake_projects/request.projects in PM and CTO pre-task manifests, independent of tracker/checkouts. Existing broker authority is unchanged: its request reads still refuse CTO (request-access-refused); tested and retained rather than widening authority. intake_question omitted because no backing request.question verb exists. staff.request/request_staff takes exactly one of request or task_ref with roles/note/decision unchanged, PM-only message.send and chief routing preserved; original request provenance/reachability uses the existing host check without a fabricated task or opened journal. Exported the existing runner toolServer for acceptance tests against production dispatch, including MCP; no new imports or sandbox/process mounts needed (import closure 1/1). PM duty prose and the generated PM staffing prompt describe pre-task intake and staffing. New tests cover actual PM/CTO/coder/reviewer/assistant bundles for pi and claude-code, assistant -> chief -> PM relays, exact user-mode decision with null task/requirement, duplicate open, wrong roles, invalid/unreachable input, exclusive staff references and assistant coordination restrictions. RED on assigned parent: 6 pass/12 fail (dispatch not exported); after exporting only the existing dispatch seam 7 pass/11 fail for missing tools/staff intake. First GREEN attempt 17/18 exposed a test fixture treating message.receive's array as an object, corrected without weakening assertions. Existing required tests first 53/55 and full harness first 222/223 failed only outdated manifest/schema/prompt expectations introduced by this slice; original parent reruns required 55/55 and bundle 22/22 passed. A further generated staffing prompt RED 21/22 proved the stale task-only instruction before updating it. Final gates: request-tools 18/18, required command 55/55, full harness 223/223, bus 1477/1477, business 305/305, sandbox import closure 1/1, all zero failures/skips/cancellations. Full logs, baseline extraction and red evidence: /mnt/storage/scratch/tmp/JHTDYm/; every run used that TMPDIR with pipefail. Local commit only; no push, no role JSON/contract/migration/schema/config/credential changes, no runtime/bootstrap execution, no continuation/PRD/advance/recovery work. Controller owns review, integration and queue/tracker coordination; linked-worktree queue command refused as designed.
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
@@ -1999,3 +1999,5 @@ are never rewritten or removed; corrections are new entries.
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-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 Terra implementation sub-agent, t3/lifecycle-task-7a-artifact-reader | #797 / Gitea #1839 lifecycle Task 7a | Bounded committed-artifact reader and real Git regressions; artifact 9/9, seat 232/232, zero skips; logs /mnt/storage/scratch/tmp/0G9g1p/; queue refused linked worktree, controller owns integration; local commit only, no push. 2026-11-03 | GPT-6.1 Terra implementation sub-agent, t3/lifecycle-task-7a-artifact-reader | #797 / Gitea #1839 lifecycle Task 7a | Bounded committed-artifact reader and real Git regressions; artifact 9/9, seat 232/232, zero skips; logs /mnt/storage/scratch/tmp/0G9g1p/; queue refused linked worktree, controller owns integration; local commit only, no push.
2026-11-04 | GPT-6.1 Sol implementation sub-agent, t3/pm-intake-tools | #797 / Gitea #1839 lifecycle Task 6a | Four backed typed intake tools, pre-task PM staff.request with original request provenance, actual pi/claude-code role manifests and production runner/MCP acceptance tests; PM duty and generated staff prose updated. CTO read manifests added while existing broker read refusal remains; intake_question absent because no backing verb. Gates request-tools 18/18, required 55/55, harness 223/223, bus 1477/1477, business 305/305, sandbox imports 1/1, zero skips; logs /mnt/storage/scratch/tmp/JHTDYm/. Parent owns review/integration/queue; local commit only, no push or authority/schema/contract/migration changes. 2026-11-04 | GPT-6.1 Sol implementation sub-agent, t3/pm-intake-tools | #797 / Gitea #1839 lifecycle Task 6a | Four backed typed intake tools, pre-task PM staff.request with original request provenance, actual pi/claude-code role manifests and production runner/MCP acceptance tests; PM duty and generated staff prose updated. CTO read manifests added while existing broker read refusal remains; intake_question absent because no backing verb. Gates request-tools 18/18, required 55/55, harness 223/223, bus 1477/1477, business 305/305, sandbox imports 1/1, zero skips; logs /mnt/storage/scratch/tmp/JHTDYm/. Parent owns review/integration/queue; local commit only, no push or authority/schema/contract/migration changes.
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); throw new BusError(code);
}; };
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x); 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. // 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_COUNT = 20;
const PAGE_BYTES = 200 * 1024; const PAGE_BYTES = 200 * 1024;
@@ -4800,6 +4821,79 @@ export class Broker {
return this.#event(s, kind, body, subject); 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 // 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. // already happened, so this records and does not re-authorize. Without one it records sync reads.
recordTask({ cap = null, business, snapshots = [], events = [], commentCursors = [] }) { 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 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, 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 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 legacy create without an operation key can't be checked that way, so it reports
`write-uncertain` and a person checks the board. `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 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 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 | | 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.assign` | pm | `task_ref`, `role` | `task.assigned` |
| `task.reassign` | 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 | | `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, 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. 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 ## Sync
Every `pollSeconds` the adapter reads the whole board, then the task list 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-invalid` | Vikunja answered 400 or 422 |
| `tracker-rate-limited` | 429 | | `tracker-rate-limited` | 429 |
| `tracker-unavailable` | 5xx or no answer on a read | | `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 | | `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 | | `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 | | `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 { lstatSync } from 'node:fs';
import { BusError } from '../../bus/src/broker.mjs'; import { BusError } from '../../bus/src/broker.mjs';
import { client, apiRoot } from './vikunja.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 { tick, reconcile } from './sync.mjs';
import { verbs } from './verbs.mjs'; import { verbs } from './verbs.mjs';
// S3's adapter for startBroker({tasks}). `trackers` is the host's plain-data boot config: // 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 (stamp(f.file) !== f.stamp) tell('credential.changed', `${ctx.business}/${instance}`, null);
if (events.length) broker.recordTask({ business: ctx.business, events }); 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) { async function start(ctx) {
try { try {
await startup(ctx); await startup(ctx);
await reconcile(ctx); await reconcile(ctx);
await reconcileCreates(ctx);
ctx.state = 'ready'; ctx.state = 'ready';
ctx.refused = null; ctx.refused = null;
if (ctx.untested) log(`tasks ${ctx.business} startup untested-version`); 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); if (ctx.state !== 'ready') throw new BusError(ctx.state === 'starting' ? 'tracker-starting' : ctx.refused);
return serial(ctx, () => ctx.handle(cap, verb, args)); 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. // 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))), start: (business) => serial(contexts.get(business), () => start(contexts.get(business))),
tick: (business) => serial(contexts.get(business), () => tick(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. // 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. // 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); const views = await all(ctx.syncCall, null, `/projects/${project}/views`, {}, ctx.per);
if (!views.ok) refuse('tracker-unavailable'); if (!views.ok) refuse('tracker-unavailable');
const kanban = views.items.filter((x) => x?.view_kind === 'kanban'); 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 { outcome, refusal } from './vikunja.mjs';
import { ref, parseRef, checkTask, taskFields, digest } from './digest.mjs'; import { ref, parseRef, checkTask, taskFields, digest } from './digest.mjs';
import { board, normal } from './sync.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'); if (args?.expect !== undefined && !/^[0-9a-f]{64}$/.test(args.expect)) fail('invalid-request');
switch (verb) { switch (verb) {
case 'task.create': { 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 // Mos's ruling: an optional project, which must be an installed project of the tree. Without
// it the task goes to the root. // it the task goes to the root.
const project = args.project === undefined ? ctx.project : args.project; const project = args.project === undefined ? ctx.project : args.project;
@@ -221,6 +223,17 @@ export function verbs(ctx) {
plan.requirement = text(args.requirement, 64); plan.requirement = text(args.requirement, 64);
if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request'); if (!/^REQ-[A-Z]+-[1-9][0-9]*$/.test(plan.requirement)) fail('invalid-request');
if (!requirements.has(plan.requirement)) fail('unknown-requirement'); 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; return plan;
} }
case 'task.assign': case 'task.assign':
@@ -386,8 +399,67 @@ export function verbs(ctx) {
fail('unknown-verb'); 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) { async function create(cap, me, plan) {
if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found'); 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); await located(plan.others);
ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null }); 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. // 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);
});