@brainervirus/workit-core 1.2.0 → 1.3.0

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.
Files changed (46) hide show
  1. package/package.json +1 -1
  2. package/skills/workit-babysit/SKILL.md +20 -26
  3. package/skills/workit-behavioral-tdd/SKILL.md +0 -5
  4. package/skills/workit-blast-radius/SKILL.md +0 -5
  5. package/skills/workit-challenge/SKILL.md +30 -47
  6. package/skills/workit-debug/SKILL.md +5 -27
  7. package/skills/workit-deslop/SKILL.md +0 -5
  8. package/skills/workit-diagram/SKILL.md +0 -5
  9. package/skills/workit-green-run/SKILL.md +0 -5
  10. package/skills/workit-handoff/SKILL.md +2 -8
  11. package/skills/workit-implement/SKILL.md +14 -20
  12. package/skills/workit-mockup/SKILL.md +0 -5
  13. package/skills/workit-plan/SKILL.md +30 -63
  14. package/skills/workit-review/SKILL.md +0 -5
  15. package/skills/workit-steer/SKILL.md +27 -27
  16. package/src/core/authority.ts +31 -8
  17. package/src/core/auto-approval.ts +44 -32
  18. package/src/core/branch.ts +293 -42
  19. package/src/core/config-conversion.ts +4 -4
  20. package/src/core/config-guard.ts +1 -7
  21. package/src/core/config.ts +1 -1
  22. package/src/core/cutover.ts +1207 -140
  23. package/src/core/doctor.ts +155 -82
  24. package/src/core/external-action-effects.ts +1102 -209
  25. package/src/core/external-action.ts +230 -103
  26. package/src/core/init.ts +25 -115
  27. package/src/core/legacy-ownership.ts +47 -0
  28. package/src/core/methods.ts +56 -52
  29. package/src/core/ports/pr-create.ts +23 -15
  30. package/src/core/pr-create.ts +374 -84
  31. package/src/core/registration.ts +0 -15
  32. package/src/core/repo-tools.ts +0 -1
  33. package/src/core/route-intent.ts +34 -82
  34. package/src/core/safe-write.ts +66 -1
  35. package/src/core/setup-state.ts +7 -0
  36. package/src/core/setup.ts +175 -82
  37. package/src/core/task-contract.ts +16 -3
  38. package/src/core/task-engine.ts +136 -31
  39. package/src/core/task-evaluation.ts +3 -5
  40. package/src/core/task-store.ts +159 -1
  41. package/src/core/tracker-issues.ts +55 -118
  42. package/src/core/vcs-config.ts +178 -187
  43. package/src/core/workers.ts +17 -0
  44. package/src/core/workspaces.ts +548 -46
  45. package/src/core.ts +1 -2
  46. package/src/core/ports/vcs-token-create-urls.ts +0 -4
@@ -29,6 +29,7 @@ import {
29
29
  type Result,
30
30
  type Scope,
31
31
  type TaskRecord,
32
+ type TaskListItem,
32
33
  type TaskSummary,
33
34
  type TaskView,
34
35
  type Utc,
@@ -152,6 +153,7 @@ type WorkerAuthority = {
152
153
  expectedWorkspaceRevision: string;
153
154
  state: NativeWorkerObservation["state"];
154
155
  session: NativeWorkerObservation["session"];
156
+ report: NativeWorkerObservation["report"] | null;
155
157
  provenance: Provenance;
156
158
  owner: object;
157
159
  store: TaskStore;
@@ -280,6 +282,7 @@ const verifyNativeWorker = (
280
282
  expectedWorkspaceRevision: expected.expectedWorkspaceRevision,
281
283
  state: expected.state,
282
284
  session: expected.session,
285
+ report: expected.report ?? null,
283
286
  provenance: result.data,
284
287
  ...binding,
285
288
  });
@@ -305,7 +308,8 @@ const takeWorker = (
305
308
  value.expectedRevision !== expected.expectedRevision ||
306
309
  value.expectedWorkspaceRevision !== expected.expectedWorkspaceRevision ||
307
310
  value.state !== expected.state ||
308
- !sameValue(value.session, expected.session)
311
+ !sameValue(value.session, expected.session) ||
312
+ !sameValue(value.report, expected.report ?? null)
309
313
  )
310
314
  return null;
311
315
  return value;
@@ -361,6 +365,8 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
361
365
  return failure("invalid_transition", "worker already has an observed session");
362
366
  if (notStarted && !["dispatching", "cancelling"].includes(entry.data.state))
363
367
  return failure("invalid_transition", "worker launch was not claimed");
368
+ if (input.report && input.state !== "stopped")
369
+ return failure("invalid_input", "worker reports require a stopped worker");
364
370
  if (
365
371
  (input.state === "running" ||
366
372
  input.state === "cancelling" ||
@@ -383,6 +389,7 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
383
389
  ...entry.data,
384
390
  state: terminalCancel ? ("cancelling" as const) : input.state,
385
391
  session: input.session,
392
+ report: entry.data.report ?? input.report ?? null,
386
393
  },
387
394
  } satisfies Entry<Worker>;
388
395
  const shouldClear =
@@ -402,6 +409,7 @@ const applyWorkerLifecycle = (input: ObserveWorkerLifecycleInput): Result<Entry<
402
409
  if (
403
410
  entry.data.state === nextEntry.data.state &&
404
411
  sameValue(entry.data.session, input.session) &&
412
+ sameValue(entry.data.report, nextEntry.data.report) &&
405
413
  !shouldClear &&
406
414
  !shouldUncertain
407
415
  )
@@ -504,6 +512,21 @@ const portableTask = (task: TaskRecord): TaskRecord => {
504
512
  };
505
513
  for (const entry of clone.assessments)
506
514
  demote(entry.data.facts, entry.data.signals, entry.data.consequences);
515
+ const sourceDecisions = new Map(task.decisions.map((entry) => [entry.id, entry]));
516
+ for (const entry of clone.decisions) {
517
+ const consumption = sourceDecisions.get(entry.id)?.data.consumption;
518
+ if (!consumption) continue;
519
+ entry.data.consumption = {
520
+ ...consumption,
521
+ state: consumption.state === "reserved" ? "uncertain" : consumption.state,
522
+ actionRef: { kind: "record", collection: "decisions", id: entry.id },
523
+ ...(consumption.historicalActionId
524
+ ? { historicalActionId: consumption.historicalActionId }
525
+ : consumption.actionRef.kind === "host"
526
+ ? { historicalActionId: consumption.actionRef.handle }
527
+ : {}),
528
+ };
529
+ }
507
530
  // Candidate metadata can contain environment-derived paths and digests. The destination
508
531
  // must recapture its own candidate instead of receiving source checkout material.
509
532
  clone.candidates = [];
@@ -641,7 +664,13 @@ const importedTask = (
641
664
  taskId: "",
642
665
  workspaceId: destinationWorkspaceId,
643
666
  },
644
- consumption: null,
667
+ consumption: data.consumption
668
+ ? {
669
+ ...data.consumption,
670
+ state: data.consumption.state === "reserved" ? "uncertain" : data.consumption.state,
671
+ actionRef: { kind: "record", collection: "decisions", id: entry.id },
672
+ }
673
+ : null,
645
674
  },
646
675
  };
647
676
  }),
@@ -858,14 +887,14 @@ export class WorkitCore {
858
887
  });
859
888
  }
860
889
 
861
- task(request: unknown): Result<TaskSummary | TaskSummary[] | TaskView> {
890
+ task(request: unknown): Result<TaskSummary | TaskListItem[] | TaskView> {
862
891
  const root = this.contextRootError();
863
892
  if (!root.ok) return root as Result<never>;
864
893
  const parsed = parseOperation("task", request);
865
894
  if (!parsed.ok) return parsed as Result<never>;
866
895
  const input = parsed.data as any;
867
896
  if ((this.context.workerId ?? null) !== null) {
868
- if (input.action !== "inspect")
897
+ if (input.action !== "inspect" && input.action !== "list")
869
898
  return failure("permission_denied", "helpers cannot control task lifecycle or scope");
870
899
  }
871
900
  switch (input.action) {
@@ -887,20 +916,67 @@ export class WorkitCore {
887
916
  case "list": {
888
917
  const listed = this.store.listTasks();
889
918
  if (!listed.ok) return listed as Result<never>;
890
- const summaries: TaskSummary[] = [];
891
- for (const task of listed.data) {
892
- const summary = this.summary(task);
893
- if (!summary.ok) return summary;
894
- summaries.push(summary.data);
895
- }
896
- return success(null, null, summaries);
919
+ const status = input.status ?? "open";
920
+ const limit = input.limit ?? 20;
921
+ const query = input.query?.toLowerCase().split(/\s+/);
922
+ const selected = listed.data
923
+ .filter((task) =>
924
+ status === "all"
925
+ ? true
926
+ : status === "closed"
927
+ ? task.status === "closed"
928
+ : task.status !== "closed",
929
+ )
930
+ .filter(
931
+ (task) =>
932
+ !query ||
933
+ query.every((term: string) =>
934
+ JSON.stringify({
935
+ id: task.id,
936
+ workspaceId: task.workspaceId,
937
+ origin: task.origin,
938
+ createdAt: task.createdAt,
939
+ updatedAt: task.updatedAt,
940
+ source: task.intent.provenance,
941
+ objective: task.intent.data.objective,
942
+ progress: task.progress,
943
+ decisions: task.decisions.map(({ data }) => data),
944
+ })
945
+ .toLowerCase()
946
+ .includes(term),
947
+ ),
948
+ )
949
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
950
+ .slice(0, limit);
951
+ if (selected.length === 0) return success(null, null, []);
952
+ const workspace = this.store.readWorkspace();
953
+ if (!workspace.ok) return workspace as Result<never>;
954
+ if (!workspace.data) return failure("not_found", "workspace not found");
955
+ const tasks = selected.map((task): TaskListItem => ({
956
+ id: task.id,
957
+ revision: task.revision,
958
+ workspaceRevision: workspace.data!.revision,
959
+ createdAt: task.createdAt,
960
+ updatedAt: task.updatedAt,
961
+ runtime: task.runtime ?? null,
962
+ objective: task.intent.data.objective,
963
+ status: task.status,
964
+ closure: task.closure,
965
+ progress: task.progress,
966
+ writer: task.status === "closed" ? null : workspace.data!.writer,
967
+ source: {
968
+ host: task.intent.provenance.host,
969
+ kind: task.intent.provenance.kind,
970
+ },
971
+ }));
972
+ return success(null, null, tasks);
897
973
  }
898
974
  case "inspect": {
899
975
  const task = this.store.readTask(input.taskId);
900
976
  if (!task.ok) return task as Result<never>;
901
977
  const helper = this.helperTaskGuard(task.data, true);
902
978
  if (!helper.ok) return helper as Result<never>;
903
- if (input.view === "summary") return this.summary(task.data);
979
+ if ((input.view ?? "summary") === "summary") return this.summary(task.data);
904
980
  return this.view(task.data);
905
981
  }
906
982
  case "pause":
@@ -1344,11 +1420,14 @@ export class WorkitCore {
1344
1420
  const evaluations = evaluateEvidence(task.data, current.data);
1345
1421
  const verified = evidence.some((entry) => {
1346
1422
  const evaluation = evaluations.find((item) => item.evidenceId === entry.id);
1347
- return findingVerificationPasses(finding.data.candidateId, {
1348
- kind: entry.data.kind,
1349
- candidateId: entry.data.candidateId,
1350
- status: evaluation?.status ?? "missing",
1351
- });
1423
+ return (
1424
+ entry.recordedAt >= finding.recordedAt &&
1425
+ findingVerificationPasses(finding.data.candidateId, {
1426
+ kind: entry.data.kind,
1427
+ candidateId: entry.data.candidateId,
1428
+ status: evaluation?.status ?? "missing",
1429
+ })
1430
+ );
1352
1431
  });
1353
1432
  if (!verified)
1354
1433
  return failure("permission_denied", "fixed findings require passing verification evidence");
@@ -1451,7 +1530,18 @@ export class WorkitCore {
1451
1530
  if (task.data.status !== "active")
1452
1531
  return failure("invalid_transition", "paused or closed tasks cannot assign workers");
1453
1532
  if (!bindingCovers(task.data.intent.data.scope, input.assignment.scope))
1454
- return failure("permission_denied", "worker assignment is outside task scope");
1533
+ return failure("permission_denied", "worker assignment is outside task scope", {
1534
+ fields: [
1535
+ {
1536
+ path: "assignment.scope",
1537
+ reason: `paths ${JSON.stringify(input.assignment.scope.paths)} must be contained by lead paths ${JSON.stringify(task.data.intent.data.scope.paths)}`,
1538
+ },
1539
+ {
1540
+ path: "task.intent.scope",
1541
+ reason: `lead paths are ${JSON.stringify(task.data.intent.data.scope.paths)}`,
1542
+ },
1543
+ ],
1544
+ });
1455
1545
  if (
1456
1546
  input.assignment.decisionIds.some(
1457
1547
  (id: string) => !task.data.decisions.some((entry) => entry.id === id),
@@ -1739,7 +1829,15 @@ export class WorkitCore {
1739
1829
  ...item,
1740
1830
  recordedAt: mutation.now,
1741
1831
  provenance: attested.data,
1742
- data: { ...item.data, state: "dispatching" as const },
1832
+ data: {
1833
+ ...item.data,
1834
+ state: "dispatching" as const,
1835
+ coordinator: {
1836
+ kind: "host" as const,
1837
+ host: this.context.caller.host,
1838
+ handle: this.context.caller.actor,
1839
+ },
1840
+ },
1743
1841
  }
1744
1842
  : item,
1745
1843
  ),
@@ -1828,6 +1926,7 @@ export class WorkitCore {
1828
1926
  expectedWorkspaceRevision: input.expectedWorkspaceRevision,
1829
1927
  state: "stopped",
1830
1928
  session: null,
1929
+ report: null,
1831
1930
  provenance: attested.data,
1832
1931
  owner: this.authorityOwner,
1833
1932
  store: this.store,
@@ -2205,11 +2304,16 @@ export class WorkitCore {
2205
2304
  const workspace = this.store.readWorkspace();
2206
2305
  if (!workspace.ok) return workspace as Result<never>;
2207
2306
  if (!workspace.data) return failure("not_found", "workspace not found");
2208
- const current = captureCandidate(this.store.root, task.intent.data.scope, environment());
2307
+ const historical = task.status === "closed" ? task.candidates.at(-1) : undefined;
2308
+ const current = historical
2309
+ ? success(null, null, historical)
2310
+ : captureCandidate(this.store.root, task.intent.data.scope, environment());
2209
2311
  if (!current.ok) return current as Result<never>;
2312
+ const evaluationWorkspace =
2313
+ task.status === "closed" ? { ...workspace.data, writer: null } : workspace.data;
2210
2314
  const requirements = evaluateRequirements(
2211
2315
  task,
2212
- workspace.data,
2316
+ evaluationWorkspace,
2213
2317
  this.context.capabilities,
2214
2318
  current.data,
2215
2319
  this.store.root,
@@ -2228,7 +2332,7 @@ export class WorkitCore {
2228
2332
  progress: task.progress,
2229
2333
  policy: task.policy,
2230
2334
  requirements,
2231
- writer: workspace.data.writer,
2335
+ writer: evaluationWorkspace.writer,
2232
2336
  });
2233
2337
  }
2234
2338
 
@@ -2236,11 +2340,16 @@ export class WorkitCore {
2236
2340
  const workspace = this.store.readWorkspace();
2237
2341
  if (!workspace.ok) return workspace as Result<never>;
2238
2342
  if (!workspace.data) return failure("not_found", "workspace not found");
2239
- const current = captureCandidate(this.store.root, task.intent.data.scope, environment());
2343
+ const historical = task.status === "closed" ? task.candidates.at(-1) : undefined;
2344
+ const current = historical
2345
+ ? success(null, null, historical)
2346
+ : captureCandidate(this.store.root, task.intent.data.scope, environment());
2240
2347
  if (!current.ok) return current as Result<never>;
2348
+ const evaluationWorkspace =
2349
+ task.status === "closed" ? { ...workspace.data, writer: null } : workspace.data;
2241
2350
  const requirements = evaluateRequirements(
2242
2351
  task,
2243
- workspace.data,
2352
+ evaluationWorkspace,
2244
2353
  this.context.capabilities,
2245
2354
  current.data,
2246
2355
  this.store.root,
@@ -2248,7 +2357,7 @@ export class WorkitCore {
2248
2357
  );
2249
2358
  return success(task.revision, workspace.data.revision, {
2250
2359
  task,
2251
- workspace: workspace.data,
2360
+ workspace: evaluationWorkspace,
2252
2361
  capabilities: this.context.capabilities,
2253
2362
  requirements,
2254
2363
  evidence: evaluateEvidence(task, current.data),
@@ -2357,8 +2466,6 @@ export class WorkitCore {
2357
2466
  (status === "active" && task.data.status !== "paused")
2358
2467
  )
2359
2468
  return failure("invalid_transition", `cannot transition ${task.data.status} to ${status}`);
2360
- if (status === "active" && input.authorityRefs.length === 0)
2361
- return failure("permission_denied", "resuming a task requires authority references");
2362
2469
  const workspace = this.store.readWorkspace();
2363
2470
  if (!workspace.ok) return workspace as Result<never>;
2364
2471
  if (!workspace.data) return failure("not_found", "workspace not found");
@@ -2403,7 +2510,7 @@ export class WorkitCore {
2403
2510
  )
2404
2511
  )
2405
2512
  return failure("recovery_required", "imported task requires resume reconciliation");
2406
- if (!validResumeApproval(task.data, workspace.data, input.authorityRefs, this.context))
2513
+ if (!validResumeApproval(task.data, workspace.data, input.authorityRefs ?? [], this.context))
2407
2514
  return failure("permission_denied", "imported resume requires native destination approval");
2408
2515
  resumeCandidate = current.data.candidate;
2409
2516
  }
@@ -2552,9 +2659,7 @@ export class WorkitCore {
2552
2659
  success(context.revision, null, {
2553
2660
  ...value,
2554
2661
  status: "closed",
2555
- candidates: value.candidates.some((item) => item.id === withCandidate.id)
2556
- ? value.candidates
2557
- : [...value.candidates, withCandidate],
2662
+ candidates: taskForView.candidates,
2558
2663
  closure: {
2559
2664
  outcome: closure.data.outcome,
2560
2665
  at: context.now,
@@ -412,17 +412,15 @@ export const resolveBinding = (
412
412
  };
413
413
  };
414
414
 
415
- /** One shared verification predicate for fixed findings: a passed
416
- * check/review counts when the finding is unscoped or the evidence is
417
- * bound to the finding's candidate. Used by the auto-reopen scan, the
418
- * resolve gate, and closure — one predicate, one error code each. */
415
+ /** Evidence evaluation establishes freshness against the current tree;
416
+ * the finding's original candidate identifies where the defect was found. */
419
417
  export const findingVerificationPasses = (
420
418
  findingCandidateId: string | null | undefined,
421
419
  evidence: { kind: string; candidateId: string | null | undefined; status: string },
422
420
  ): boolean =>
423
421
  evidence.status === "passed" &&
424
422
  (evidence.kind === "check" || evidence.kind === "review") &&
425
- ((findingCandidateId ?? null) === null || evidence.candidateId === findingCandidateId);
423
+ ((findingCandidateId ?? null) === null || evidence.candidateId != null);
426
424
  const applicableDecision = (
427
425
  task: TaskRecord,
428
426
  workspace: WorkspaceRecord,
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import * as fs from "node:fs";
3
4
  import { hostname } from "node:os";
4
5
  import path from "node:path";
@@ -76,6 +77,7 @@ export type MetadataLock = {
76
77
  processStart: string | null;
77
78
  host: string;
78
79
  nonce: string;
80
+ externalAction?: true;
79
81
  };
80
82
  export type ProcessEvidence = {
81
83
  state: "stopped" | "accounted_for";
@@ -100,6 +102,7 @@ const metadataLockSchema = z
100
102
  processStart: z.string().nullable(),
101
103
  host: z.string().min(1),
102
104
  nonce: z.string().min(1),
105
+ externalAction: z.literal(true).optional(),
103
106
  })
104
107
  .strict();
105
108
  export type RecoveryCandidate = {
@@ -170,7 +173,35 @@ const sameMetadataLock = (left: unknown, right: MetadataLock): boolean => {
170
173
  const parsed = metadataLockSchema.safeParse(left);
171
174
  return parsed.success && canonicalJson(parsed.data) === canonicalJson(right);
172
175
  };
176
+ export const sameDirectoryIdentity = (left: string, right: string): boolean => {
177
+ if (!path.isAbsolute(left) || !path.isAbsolute(right)) return false;
178
+ const normalizedLeft = path.resolve(left);
179
+ const normalizedRight = path.resolve(right);
180
+ try {
181
+ const a = fs.statSync(normalizedLeft, { bigint: true });
182
+ const b = fs.statSync(normalizedRight, { bigint: true });
183
+ if (!a.isDirectory() || !b.isDirectory()) return false;
184
+ if (
185
+ process.platform === "win32"
186
+ ? normalizedLeft.toLowerCase() === normalizedRight.toLowerCase()
187
+ : normalizedLeft === normalizedRight
188
+ )
189
+ return true;
190
+ const canonicalLeft = fs.realpathSync(normalizedLeft);
191
+ const canonicalRight = fs.realpathSync(normalizedRight);
192
+ if (
193
+ process.platform === "win32"
194
+ ? canonicalLeft.toLowerCase() === canonicalRight.toLowerCase()
195
+ : canonicalLeft === canonicalRight
196
+ )
197
+ return true;
198
+ return a.dev === b.dev && a.ino !== 0n && a.ino === b.ino;
199
+ } catch {
200
+ return false;
201
+ }
202
+ };
173
203
  type LockSnapshot = { raw: string; data: MetadataLock };
204
+ const externalActionLockRoots = new AsyncLocalStorage<ReadonlySet<string>>();
174
205
 
175
206
  export class TaskStore {
176
207
  readonly root: string;
@@ -230,7 +261,11 @@ export class TaskStore {
230
261
  readWorkspace(): Result<WorkspaceRecord | null> {
231
262
  const item = this.readRecord<WorkspaceRecord>(this.workspacePath, workspaceRecordSchema);
232
263
  if (!item.exists) return success(null, null, null);
233
- if (item.result.ok && item.result.data.root !== this.root)
264
+ if (
265
+ item.result.ok &&
266
+ item.result.data.root !== this.root &&
267
+ !sameDirectoryIdentity(item.result.data.root, this.root)
268
+ )
234
269
  return failure("recovery_required", "workspace root binding is invalid", {
235
270
  path: this.workspacePath,
236
271
  });
@@ -355,6 +390,37 @@ export class TaskStore {
355
390
  return this.withLock<TaskRecord>(() => {
356
391
  const current = this.readWorkspace();
357
392
  if (!current.ok) return current;
393
+ const origin = input.task.origin;
394
+ if (current.data && origin) {
395
+ if (origin.workspaceId === current.data.id) {
396
+ const source = this.readTask(origin.taskId);
397
+ if (source.ok) return success(source.data.revision, current.data.revision, source.data);
398
+ if (source.code !== "not_found") return source as Result<never>;
399
+ return failure("not_found", "source task is not present in this shared workspace");
400
+ }
401
+ const tasks = this.listTasks();
402
+ if (!tasks.ok) return tasks as Result<never>;
403
+ const prior = tasks.data.filter(
404
+ (task) =>
405
+ task.origin?.workspaceId === origin.workspaceId &&
406
+ task.origin?.taskId === origin.taskId,
407
+ );
408
+ if (prior.length > 1)
409
+ return failure(
410
+ "recovery_required",
411
+ `source task has multiple imported mappings: ${prior.map((task) => task.id).join(", ")}`,
412
+ );
413
+ if (prior.length === 1) {
414
+ const existing = prior[0]!;
415
+ if (existing.origin!.exportDigest !== origin.exportDigest)
416
+ return failure(
417
+ "revision_conflict",
418
+ "source task export changed; reconcile the mapped destination task before importing",
419
+ { taskId: existing.id },
420
+ );
421
+ return success(existing.revision, current.data.revision, existing);
422
+ }
423
+ }
358
424
  if (
359
425
  current.data
360
426
  ? input.expectedWorkspaceRevision !== current.data.revision
@@ -822,6 +888,90 @@ export class TaskStore {
822
888
  };
823
889
  }
824
890
 
891
+ private externalActionLockOptions(): FileLockSyncAcquireOptions<MetadataLock> {
892
+ const options = this.metadataLockOptions();
893
+ return { ...options, payload: () => ({ ...options.payload(), externalAction: true }) };
894
+ }
895
+
896
+ /** Hold the workspace metadata lock across a managed effect in this checkout. */
897
+ async withExternalActionLock<T>(
898
+ operation: () => Promise<Result<T>>,
899
+ reentrant = false,
900
+ ): Promise<Result<T>> {
901
+ const activeRoots = externalActionLockRoots.getStore();
902
+ if (
903
+ activeRoots &&
904
+ [...activeRoots].some((root) => root === this.root || sameDirectoryIdentity(root, this.root))
905
+ )
906
+ return operation();
907
+ try {
908
+ this.initializeMutationStorage();
909
+ } catch (error) {
910
+ return failure("storage_error", `unable to initialize store: ${String(error)}`, {
911
+ path: this.workitDir,
912
+ });
913
+ }
914
+ let handle: FileLockSyncHandle;
915
+ try {
916
+ handle = acquireFileLockSync(this.workspacePath, this.externalActionLockOptions());
917
+ } catch (error) {
918
+ return this.lockFailure(error);
919
+ }
920
+ let result: Result<T>;
921
+ try {
922
+ if (!handle.verifyStillHeld())
923
+ result = failure("recovery_required", "metadata lock was compromised", {
924
+ path: this.lockPath,
925
+ });
926
+ else {
927
+ try {
928
+ const run = () => operation();
929
+ result = await (reentrant
930
+ ? externalActionLockRoots.run(new Set([...(activeRoots ?? []), this.root]), run)
931
+ : run());
932
+ } catch {
933
+ result = failure("external_outcome_unknown", "external action outcome is unknown", {
934
+ outcome: "unknown",
935
+ });
936
+ }
937
+ try {
938
+ if (!handle.verifyStillHeld())
939
+ result = failure("external_outcome_unknown", "metadata lock was compromised", {
940
+ outcome: "unknown",
941
+ path: this.lockPath,
942
+ });
943
+ } catch {
944
+ result = failure("external_outcome_unknown", "metadata lock could not be verified", {
945
+ outcome: "unknown",
946
+ path: this.lockPath,
947
+ });
948
+ }
949
+ }
950
+ } catch {
951
+ result = failure("external_outcome_unknown", "external action lock operation failed", {
952
+ outcome: "unknown",
953
+ path: this.lockPath,
954
+ });
955
+ }
956
+ try {
957
+ handle.release();
958
+ } catch (error) {
959
+ const released = this.lockFailure(error);
960
+ const releaseError = released.ok ? "metadata lock release failed" : released.error;
961
+ return result.ok
962
+ ? failure(
963
+ "external_outcome_unknown",
964
+ "external action metadata lock release is uncertain",
965
+ {
966
+ outcome: "unknown",
967
+ path: this.lockPath,
968
+ },
969
+ )
970
+ : failure("recovery_required", `${result.error}; ${releaseError}`, result.details);
971
+ }
972
+ return result;
973
+ }
974
+
825
975
  private recoveryLockOptions(
826
976
  observed: LockSnapshot | null,
827
977
  evidence: ProcessEvidence,
@@ -912,6 +1062,14 @@ export class TaskStore {
912
1062
  private lockFailure(error: unknown): Result<never> {
913
1063
  const value = error as { code?: unknown; message?: unknown };
914
1064
  const code = typeof value?.code === "string" ? value.code : "";
1065
+ if (code === "EEXIST" || code === "file_lock_timeout") {
1066
+ const lock = this.readLockSnapshot();
1067
+ if (lock.ok && lock.data?.data.externalAction)
1068
+ return failure("writer_conflict", "workspace is reserved by a managed external action", {
1069
+ outcome: "not_started",
1070
+ path: this.lockPath,
1071
+ });
1072
+ }
915
1073
  const recovery =
916
1074
  code === "EEXIST" ||
917
1075
  code === "file_lock_timeout" ||