@brainervirus/workit-core 2.2.0 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -113,8 +113,10 @@ values are still concurrency-checked, so never copy revisions between calls.
113
113
  A busy result means another live Workit call holds the checkout lock: retry the
114
114
  same call; it is not a recovery condition. A lock left by a dead process is
115
115
  reclaimed on the next write, and \`workit doctor --fix-lock\` clears it on demand.
116
- A revision_conflict on a call that omitted expectedRevision is contention too:
117
- re-read the record and retry the call.
116
+ An omitted revision absorbs a concurrent write: Workit re-reads, re-checks
117
+ policy, and reapplies the call, returning busy under persistent contention. A
118
+ revision_conflict means a revision you passed is stale: re-read the record
119
+ before deciding whether to retry.
118
120
  A solo edit does not need writer acquisition; use it when concurrent checkout
119
121
  writers need coordination. Record only observed facts and checks. Evidence can
120
122
  become stale when its bound candidate changes; reconcile findings against the
@@ -60,6 +60,7 @@ import {
60
60
  import { diffPolicy, resolvePolicy } from "./policy-resolver";
61
61
  import { verifyStandingApproval } from "./auto-approval";
62
62
  import { TaskStore, type MetadataLock, type ProcessEvidence } from "./task-store";
63
+ import { defaultLockTimeout } from "./store-lock";
63
64
  import {
64
65
  compactTaskContext,
65
66
  exportDigest,
@@ -452,6 +453,54 @@ const refsWithinScope = (refs: Ref[], scope: Scope): boolean =>
452
453
  bindingCovers(scope, { description: "", paths: [ref.path], exclusions: [] }),
453
454
  );
454
455
 
456
+ /** Commit attempts for a call whose revisions the engine filled (see retryFilledRevisions). */
457
+ const REVISION_RETRY_ATTEMPTS = 8;
458
+ const retryPause = new Int32Array(new SharedArrayBuffer(4));
459
+ /** Which revisions the caller left for the engine to fill from its own read. */
460
+ type FilledRevisions = { task: boolean; workspace: boolean };
461
+ const filledRevisions = (request: unknown): FilledRevisions => {
462
+ const value = (typeof request === "object" && request !== null ? request : {}) as {
463
+ expectedRevision?: unknown;
464
+ expectedWorkspaceRevision?: unknown;
465
+ };
466
+ return {
467
+ task: value.expectedRevision === undefined,
468
+ workspace: value.expectedWorkspaceRevision === undefined,
469
+ };
470
+ };
471
+ /**
472
+ * A store compare-and-swap rejection (it carries the actual revision) on a
473
+ * revision the engine filled. A conflict on a caller-supplied revision, or a
474
+ * semantic conflict such as a changed import source, is never retried.
475
+ */
476
+ const filledConflict = (result: Result<unknown>, filled: FilledRevisions): boolean =>
477
+ !result.ok &&
478
+ result.code === "revision_conflict" &&
479
+ (("actualRevision" in result.details && filled.task) ||
480
+ ("actualWorkspaceRevision" in result.details && filled.workspace));
481
+ /**
482
+ * Re-run `run` while it loses a compare-and-swap race on an engine-filled
483
+ * revision. Each attempt commits at most once and a CAS rejection commits
484
+ * nothing, so a success is applied exactly once. Attempts stop at
485
+ * REVISION_RETRY_ATTEMPTS or once the lock budget has elapsed — so blocking
486
+ * stays within about twice that budget — and end as retryable busy.
487
+ */
488
+ const retryFilledRevisions = <T>(filled: FilledRevisions, run: () => Result<T>): Result<T> => {
489
+ if (!filled.task && !filled.workspace) return run();
490
+ let result = run();
491
+ // The deadline starts after the first attempt, so a slow first lock wait
492
+ // still leaves at least one retry.
493
+ const deadline = Date.now() + defaultLockTimeout();
494
+ for (let attempt = 1; filledConflict(result, filled); attempt += 1) {
495
+ if (attempt >= REVISION_RETRY_ATTEMPTS || Date.now() >= deadline)
496
+ return failure("busy", "records kept changing under concurrent writers; retry the call");
497
+ // Jittered backoff de-synchronizes writers that lost the same race.
498
+ Atomics.wait(retryPause, 0, 0, Math.floor(Math.random() * 4 * attempt) + 1);
499
+ result = run();
500
+ }
501
+ return result;
502
+ };
503
+
455
504
  const trustedNow = (context: OperationContext): Utc =>
456
505
  typeof context.now === "function" ? context.now() : context.now;
457
506
 
@@ -747,6 +796,17 @@ export class WorkitCore {
747
796
  input.expectedWorkspaceRevision = workspace ? workspace.revision : null;
748
797
  }
749
798
 
799
+ /**
800
+ * A revision the caller omitted is not a compare-and-swap request: when
801
+ * another writer commits between this call's read and its locked commit,
802
+ * re-run the whole operation — fresh read, policy, requirement and
803
+ * candidate checks, then commit — so a re-check that now fails returns that
804
+ * failure. Caller-supplied revisions stay strict CAS.
805
+ */
806
+ private retryOmittedRevisions<T>(request: unknown, run: () => Result<T>): Result<T> {
807
+ return retryFilledRevisions(filledRevisions(request), run);
808
+ }
809
+
750
810
  private helperEntry(
751
811
  task: TaskRecord,
752
812
  requireSession = false,
@@ -786,6 +846,15 @@ export class WorkitCore {
786
846
  }
787
847
 
788
848
  state(request: unknown): Result<ExportBundle | TaskSummary | TaskRecord | WorkspaceRecord> {
849
+ // Recovery is an operator CAS over snapshot bytes; only import retries.
850
+ return (request as { action?: unknown } | null)?.action === "import"
851
+ ? this.retryOmittedRevisions(request, () => this.stateOnce(request))
852
+ : this.stateOnce(request);
853
+ }
854
+
855
+ private stateOnce(
856
+ request: unknown,
857
+ ): Result<ExportBundle | TaskSummary | TaskRecord | WorkspaceRecord> {
789
858
  const root = this.contextRootError();
790
859
  if (!root.ok) return root;
791
860
  if ((this.context.workerId ?? null) !== null)
@@ -890,6 +959,10 @@ export class WorkitCore {
890
959
  }
891
960
 
892
961
  task(request: unknown): Result<TaskSummary | TaskListItem[] | TaskView> {
962
+ return this.retryOmittedRevisions(request, () => this.taskOnce(request));
963
+ }
964
+
965
+ private taskOnce(request: unknown): Result<TaskSummary | TaskListItem[] | TaskView> {
893
966
  const root = this.contextRootError();
894
967
  if (!root.ok) return root;
895
968
  const parsed = parseOperation("task", request);
@@ -997,6 +1070,10 @@ export class WorkitCore {
997
1070
  }
998
1071
 
999
1072
  policy(request: unknown): Result<Policy | null> {
1073
+ return this.retryOmittedRevisions(request, () => this.policyOnce(request));
1074
+ }
1075
+
1076
+ private policyOnce(request: unknown): Result<Policy | null> {
1000
1077
  const root = this.contextRootError();
1001
1078
  if (!root.ok) return root;
1002
1079
  const parsed = parseOperation("policy", request);
@@ -1049,6 +1126,10 @@ export class WorkitCore {
1049
1126
  }
1050
1127
 
1051
1128
  evidence(request: unknown): Result<Entry<Evidence>> {
1129
+ return this.retryOmittedRevisions(request, () => this.evidenceOnce(request));
1130
+ }
1131
+
1132
+ private evidenceOnce(request: unknown): Result<Entry<Evidence>> {
1052
1133
  const root = this.contextRootError();
1053
1134
  if (!root.ok) return root;
1054
1135
  const parsed = parseOperation("evidence", request);
@@ -1176,6 +1257,36 @@ export class WorkitCore {
1176
1257
  return this.recordDecision(request, undefined, true, true);
1177
1258
  }
1178
1259
 
1260
+ /**
1261
+ * Commit a task mutation whose update re-validates everything it depends on
1262
+ * under the lock. Decisions use this instead of a whole-operation retry
1263
+ * because a native receipt is retired before the commit and cannot be
1264
+ * verified twice: a CAS loss on an engine-filled revision re-reads the
1265
+ * record, repeats `recheck` against it and retries the commit; the update's
1266
+ * in-lock receipt check keeps a receipt from being consumed twice.
1267
+ */
1268
+ private commitTask(
1269
+ taskId: string,
1270
+ expected: string,
1271
+ filled: boolean,
1272
+ recheck: (fresh: TaskRecord) => Result<unknown>,
1273
+ update: Parameters<TaskStore["mutateTask"]>[2],
1274
+ ): Result<TaskRecord> {
1275
+ let revision = expected;
1276
+ let first = true;
1277
+ return retryFilledRevisions({ task: filled, workspace: false }, () => {
1278
+ if (!first) {
1279
+ const fresh = this.store.readTask(taskId);
1280
+ if (!fresh.ok) return fresh;
1281
+ const checked = recheck(fresh.data);
1282
+ if (!checked.ok) return checked;
1283
+ revision = fresh.data.revision;
1284
+ }
1285
+ first = false;
1286
+ return this.store.mutateTask(taskId, revision, update, trustedNow(this.context));
1287
+ });
1288
+ }
1289
+
1179
1290
  private recordDecision(
1180
1291
  request: unknown,
1181
1292
  nativeObservation?: unknown,
@@ -1214,6 +1325,7 @@ export class WorkitCore {
1214
1325
  if (!task.ok) return task;
1215
1326
  if ((this.context.workerId ?? null) !== null)
1216
1327
  return failure("permission_denied", "helpers cannot record or revoke decisions");
1328
+ const filled = input.expectedRevision === undefined;
1217
1329
  this.fillRevisions(input, task.data);
1218
1330
  if (input.action === "record") {
1219
1331
  if (task.data.status === "closed")
@@ -1269,10 +1381,31 @@ export class WorkitCore {
1269
1381
  const nativeProvenance = native?.ok ? retireNativeAuthority(native.data) : null;
1270
1382
  if (native?.ok && !nativeProvenance)
1271
1383
  return failure("permission_denied", "native decision authority was retired");
1272
- const changed = this.store.mutateTask(
1384
+ // A retry repeats the pre-lock checks that depend on the task record and
1385
+ // records the provenance of the standing re-verification it passed;
1386
+ // closure, receipt reuse, content and requirements are re-checked in the lock.
1387
+ let standingProvenance = standingApproval?.ok === true ? standingApproval.data : null;
1388
+ const recheck = (fresh: TaskRecord): Result<unknown> => {
1389
+ if (fresh.status === "closed")
1390
+ return failure("invalid_transition", "closed task cannot record a decision");
1391
+ if (!standingProvenance) return success(null, null, null);
1392
+ const again = verifyStandingApproval(
1393
+ this.store.root,
1394
+ fresh,
1395
+ this.context.caller,
1396
+ input.binding,
1397
+ );
1398
+ if (again.ok) standingProvenance = again.data;
1399
+ return again;
1400
+ };
1401
+ const changed = this.commitTask(
1273
1402
  task.data.id,
1274
1403
  input.expectedRevision,
1404
+ filled,
1405
+ recheck,
1275
1406
  (current, mutation) => {
1407
+ if (current.status === "closed")
1408
+ return failure("invalid_transition", "closed task cannot record a decision");
1276
1409
  const latestContent = verifyDecisionContent(this.store, input.binding);
1277
1410
  if (!latestContent.ok) return latestContent;
1278
1411
  const known = new Set(current.policy?.requirements.map((item) => item.id) ?? []);
@@ -1293,9 +1426,7 @@ export class WorkitCore {
1293
1426
  id: newId(),
1294
1427
  recordedAt: mutation.now,
1295
1428
  provenance:
1296
- standingApproval?.ok === true
1297
- ? standingApproval.data
1298
- : (nativeProvenance ?? provenance(this.context, "agent_reported")),
1429
+ standingProvenance ?? nativeProvenance ?? provenance(this.context, "agent_reported"),
1299
1430
  data,
1300
1431
  };
1301
1432
  return success(mutation.revision, null, {
@@ -1303,7 +1434,6 @@ export class WorkitCore {
1303
1434
  decisions: [...current.decisions, entry],
1304
1435
  });
1305
1436
  },
1306
- trustedNow(this.context),
1307
1437
  );
1308
1438
  if (!changed.ok) return changed;
1309
1439
  return success(changed.data.revision, null, changed.data.decisions.at(-1)!);
@@ -1315,9 +1445,11 @@ export class WorkitCore {
1315
1445
  if (decision.data.revoked) return failure("invalid_transition", "decision is already revoked");
1316
1446
  if (decision.data.consumption)
1317
1447
  return failure("permission_denied", "consumed decision cannot be revoked");
1318
- const changed = this.store.mutateTask(
1448
+ const changed = this.commitTask(
1319
1449
  task.data.id,
1320
1450
  input.expectedRevision,
1451
+ filled,
1452
+ () => success(null, null, null),
1321
1453
  (current, mutation) => {
1322
1454
  const entry = current.decisions.find((candidate) => candidate.id === input.decisionId);
1323
1455
  if (!entry) return failure("not_found", "decision not found");
@@ -1336,7 +1468,6 @@ export class WorkitCore {
1336
1468
  ),
1337
1469
  });
1338
1470
  },
1339
- trustedNow(this.context),
1340
1471
  );
1341
1472
  if (!changed.ok) return changed;
1342
1473
  const entry = changed.data.decisions.find((candidate) => candidate.id === input.decisionId);
@@ -1346,6 +1477,10 @@ export class WorkitCore {
1346
1477
  }
1347
1478
 
1348
1479
  finding(request: unknown): Result<Entry<Finding>> {
1480
+ return this.retryOmittedRevisions(request, () => this.findingOnce(request));
1481
+ }
1482
+
1483
+ private findingOnce(request: unknown): Result<Entry<Finding>> {
1349
1484
  const root = this.contextRootError();
1350
1485
  if (!root.ok) return root;
1351
1486
  const parsed = parseOperation("finding", request);
@@ -1515,6 +1650,10 @@ export class WorkitCore {
1515
1650
  }
1516
1651
 
1517
1652
  worker(request: unknown): Result<Entry<Worker>> {
1653
+ return this.retryOmittedRevisions(request, () => this.workerOnce(request));
1654
+ }
1655
+
1656
+ private workerOnce(request: unknown): Result<Entry<Worker>> {
1518
1657
  const root = this.contextRootError();
1519
1658
  if (!root.ok) return root;
1520
1659
  const parsed = parseOperation("worker", request);
@@ -1950,6 +2089,10 @@ export class WorkitCore {
1950
2089
  }
1951
2090
 
1952
2091
  writer(request: unknown): Result<WorkspaceRecord> {
2092
+ return this.retryOmittedRevisions(request, () => this.writerOnce(request));
2093
+ }
2094
+
2095
+ private writerOnce(request: unknown): Result<WorkspaceRecord> {
1953
2096
  const root = this.contextRootError();
1954
2097
  if (!root.ok) return root;
1955
2098
  const parsed = parseOperation("writer", request);
@@ -703,7 +703,8 @@ export class TaskStore {
703
703
  const current = this.readWorkspace();
704
704
  if (!current.ok) return current;
705
705
  if (!current.data) return failure("not_found", "workspace not found");
706
- if (current.data.revision !== expected) return this.conflict(expected, current.data.revision);
706
+ if (current.data.revision !== expected)
707
+ return this.workspaceConflict(expected, current.data.revision);
707
708
  const previousBytes = this.snapshotBytes(this.workspacePath);
708
709
  if (!previousBytes)
709
710
  return failure("storage_error", "workspace snapshot disappeared during mutation");
@@ -741,7 +742,7 @@ export class TaskStore {
741
742
  if (!expected) return failure("invalid_input", "task revision is required");
742
743
  if (task.data.revision !== expected) return this.conflict(expected, task.data.revision);
743
744
  if (workspace.data.revision !== input.expectedWorkspaceRevision)
744
- return this.conflict(input.expectedWorkspaceRevision, workspace.data.revision);
745
+ return this.workspaceConflict(input.expectedWorkspaceRevision, workspace.data.revision);
745
746
  const previousWorkspaceBytes = this.snapshotBytes(this.workspacePath);
746
747
  const previousTaskBytes = this.snapshotBytes(this.taskPath(input.taskId));
747
748
  if (!previousWorkspaceBytes || !previousTaskBytes)
@@ -1783,6 +1784,14 @@ export class TaskStore {
1783
1784
  );
1784
1785
  }
1785
1786
 
1787
+ private workspaceConflict(expected: Revision, actual: Revision): Result<never> {
1788
+ return failure(
1789
+ "revision_conflict",
1790
+ "workspace revision does not match; omit expectedWorkspaceRevision to use the current record",
1791
+ { expectedWorkspaceRevision: expected, actualWorkspaceRevision: actual },
1792
+ );
1793
+ }
1794
+
1786
1795
  private taskPath(taskId: Id) {
1787
1796
  return path.join(this.tasksDir, `${taskId}.json`);
1788
1797
  }
package/src/git/rev.ts CHANGED
@@ -214,6 +214,23 @@ export type WorktreeTree = {
214
214
  skipped: Array<{ path: string; size: number; mtimeMs: number }>;
215
215
  };
216
216
 
217
+ /**
218
+ * Copy the index and keep its mtime. Git trusts an entry's cached stat only
219
+ * when the entry is older than the index file ("racy git"). An entry written
220
+ * in the same timestamp tick as the index is re-hashed instead. A plain copy
221
+ * stamps the index with the current time, so a same-size rewrite in that
222
+ * tick would read as clean. That is common on git builds that compare whole
223
+ * seconds (macOS). The copy's mtime is floored one microsecond below the
224
+ * original: an older index only makes more entries racy, which is the safe
225
+ * direction.
226
+ */
227
+ const seedIndex = (from: string, to: string): void => {
228
+ fs.copyFileSync(from, to);
229
+ const { atimeNs, mtimeNs } = fs.statSync(from, { bigint: true });
230
+ const seconds = (ns: bigint): number => (Number(ns / 1000n) - 1) / 1e6;
231
+ fs.utimesSync(to, seconds(atimeNs), seconds(mtimeNs));
232
+ };
233
+
217
234
  /**
218
235
  * The tree the worktree would commit right now, including unstaged and
219
236
  * untracked (non-ignored) files. Built in a throwaway index
@@ -241,7 +258,7 @@ export function worktreeTree(
241
258
  const tempIndex = path.join(scratch, "index");
242
259
  // Seeding from the real index keeps git's stat cache, so `add -A` only
243
260
  // rehashes files that actually changed.
244
- if (fs.existsSync(indexPath)) fs.copyFileSync(indexPath, tempIndex);
261
+ if (fs.existsSync(indexPath)) seedIndex(indexPath, tempIndex);
245
262
  const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
246
263
  const untracked = git(top, ["ls-files", "--others", "--exclude-standard", "-z"], {
247
264
  env,