@brainervirus/workit-core 2.2.0 → 2.2.1
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 +1 -1
- package/src/core/methods.ts +4 -2
- package/src/core/task-engine.ts +150 -7
- package/src/core/task-store.ts +11 -2
package/package.json
CHANGED
package/src/core/methods.ts
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
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
|
package/src/core/task-engine.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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);
|
package/src/core/task-store.ts
CHANGED
|
@@ -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)
|
|
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.
|
|
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
|
}
|