@ricsam/r5d-worker 0.0.53 → 0.0.55

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.
@@ -2,7 +2,9 @@ import { createHash } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { managedBranchPath } from "./managed-paths.mjs";
5
+ import { WorkspaceMutationGate } from "./workspace-mutation-gate.mjs";
5
6
  const WORKSPACE_BRANCH = "main";
7
+ const WORKSPACE_INTENT_REF_PREFIX = "refs/r5d/workspace-intents/";
6
8
  const MAX_WORKSPACE_SYNC_DIFF_BYTES = 5 * 1024 * 1024;
7
9
  const WORKSPACE_PERIODIC_SCAN_INTERVAL_MS = 5e3;
8
10
  const MAX_REPORTED_WORKSPACE_PATHS = 2e3;
@@ -1072,10 +1074,27 @@ function commitMessage(input) {
1072
1074
  ...input.confirmedLargeDiff ? { confirmedLargeDiff: true, confirmationReason: input.confirmationReason } : {}
1073
1075
  });
1074
1076
  }
1075
- function isNonFastForward(result) {
1076
- const output = `${result.stdout}
1077
- ${result.stderr}`.toLowerCase();
1078
- return output.includes("non-fast-forward") || output.includes("fetch first") || output.includes("[rejected]");
1077
+ function assertWorkspaceQuarantineRef(quarantineRef) {
1078
+ const suffix = quarantineRef.slice(WORKSPACE_INTENT_REF_PREFIX.length);
1079
+ if (!quarantineRef.startsWith(WORKSPACE_INTENT_REF_PREFIX) || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(suffix)) {
1080
+ throw new Error(`Workspace candidate ref must be below ${WORKSPACE_INTENT_REF_PREFIX}`);
1081
+ }
1082
+ const result = Bun.spawnSync(["git", "check-ref-format", quarantineRef], {
1083
+ stdout: "pipe",
1084
+ stderr: "pipe",
1085
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
1086
+ });
1087
+ if (result.exitCode !== 0) {
1088
+ throw new Error(`Invalid workspace candidate ref ${JSON.stringify(quarantineRef)}`);
1089
+ }
1090
+ }
1091
+ function uploadWorkspaceCandidate(input, candidateHead) {
1092
+ const quarantineRef = input.quarantineRef;
1093
+ if (!quarantineRef) {
1094
+ throw new Error("A server-issued workspace candidate ref is required before uploading a canonical workspace candidate");
1095
+ }
1096
+ assertWorkspaceQuarantineRef(quarantineRef);
1097
+ return runGitResult(input, input.shadowRoot, ["push", "origin", `HEAD:${quarantineRef}`]);
1079
1098
  }
1080
1099
  function resetShadowToRemote(input, opaqueWorkspaceRoots = []) {
1081
1100
  runGit(input, input.shadowRoot, ["fetch", "origin", "--prune"], "fetch canonical workspace before reset");
@@ -1094,6 +1113,7 @@ function baseResult(input, startingHead) {
1094
1113
  workerLabel: input.workerLabel,
1095
1114
  trigger: input.trigger,
1096
1115
  startingHead,
1116
+ expectedHead: startingHead,
1097
1117
  rebaseCount: 0,
1098
1118
  diffSizeBytes: 0,
1099
1119
  gitStatus: "",
@@ -1197,7 +1217,7 @@ async function synchronizeWorkspace(rawInput) {
1197
1217
  if (stagedWorkingPaths.length > 0) {
1198
1218
  runGit(input, input.shadowRoot, ["commit", "-m", commitMessage(input)], "commit workspace changes");
1199
1219
  }
1200
- const candidateHead = revParse(input, "HEAD");
1220
+ let candidateHead = revParse(input, "HEAD");
1201
1221
  if (candidateHead) assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, candidateHead);
1202
1222
  const hasRemoteMain = Boolean(revParse(input, `origin/${WORKSPACE_BRANCH}`));
1203
1223
  const hasUnpushedCommit = candidateHead ? !hasRemoteMain || !tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", candidateHead, `origin/${WORKSPACE_BRANCH}`]) : false;
@@ -1233,58 +1253,59 @@ async function synchronizeWorkspace(rawInput) {
1233
1253
  };
1234
1254
  }
1235
1255
  let rebaseCount = 0;
1236
- for (let pushAttempt = 0; pushAttempt < 3; pushAttempt += 1) {
1237
- const push = runGitResult(input, input.shadowRoot, ["push", "origin", `HEAD:refs/heads/${WORKSPACE_BRANCH}`]);
1238
- if (push.exitCode === 0) {
1239
- const publishedHead = revParse(input, "HEAD") ?? candidateHead;
1240
- if (publishedHead) assertPublishedCanonicalCheckoutTree(input, publishedHead, sampledCanonicalCheckoutTreeHash);
1241
- mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
1242
- return {
1243
- ...observed,
1244
- outcome: "published",
1245
- candidateHead: candidateHead ?? void 0,
1246
- publishedHead: publishedHead ?? void 0,
1247
- rebaseCount,
1248
- gitStatus: gitStatus(input)
1249
- };
1250
- }
1251
- if (!isNonFastForward(push)) {
1252
- return {
1253
- ...observed,
1254
- outcome: "failed",
1255
- candidateHead: candidateHead ?? void 0,
1256
- rebaseCount,
1257
- error: push.stderr || push.stdout || "Workspace push failed",
1258
- gitStatus: gitStatus(input)
1259
- };
1260
- }
1261
- runGit(input, input.shadowRoot, ["fetch", "origin", "--prune"], "fetch workspace after push race");
1256
+ const expectedHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
1257
+ if (candidateHead && expectedHead && !tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", expectedHead, candidateHead])) {
1262
1258
  const rebase = runGitResult(input, input.shadowRoot, ["rebase", `origin/${WORKSPACE_BRANCH}`]);
1263
1259
  rebaseCount += 1;
1264
1260
  if (rebase.exitCode !== 0) {
1265
1261
  const conflicted = runGitResult(input, input.shadowRoot, ["diff", "--name-only", "--diff-filter=U", "-z"]);
1266
- const discardedPaths = reportedPaths([.../* @__PURE__ */ new Set([...paths, ...conflicted.stdout.split("\0").filter(Boolean)])].sort());
1267
- const publishedHead = resetShadowToRemote(input, mirroredGitlinkProjection.opaqueRoots);
1262
+ const conflictPaths = reportedPaths([.../* @__PURE__ */ new Set([...paths, ...conflicted.stdout.split("\0").filter(Boolean)])].sort());
1263
+ runGit(input, input.shadowRoot, ["rebase", "--abort"], "preserve workspace candidate after rebase conflict");
1268
1264
  return {
1269
1265
  ...observed,
1270
- outcome: "conflict_reset",
1266
+ outcome: "failed",
1267
+ expectedHead,
1271
1268
  candidateHead: candidateHead ?? void 0,
1272
- publishedHead: publishedHead ?? void 0,
1273
1269
  rebaseCount,
1274
- discardedPaths,
1275
- localChangesDiscarded: true,
1270
+ affectedPaths: conflictPaths,
1271
+ error: `Workspace candidate conflicts with canonical head ${expectedHead}; local changes were preserved for reconciliation.`,
1276
1272
  gitStatus: gitStatus(input)
1277
1273
  };
1278
1274
  }
1275
+ candidateHead = revParse(input, "HEAD");
1279
1276
  assertPublishedCanonicalCheckoutTree(input, "HEAD", sampledCanonicalCheckoutTreeHash);
1280
1277
  assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, "HEAD");
1281
1278
  }
1279
+ if (!candidateHead) {
1280
+ return {
1281
+ ...observed,
1282
+ outcome: "failed",
1283
+ rebaseCount,
1284
+ error: "Workspace candidate does not have a commit",
1285
+ gitStatus: gitStatus(input)
1286
+ };
1287
+ }
1288
+ const upload = uploadWorkspaceCandidate(input, candidateHead);
1289
+ if (upload.exitCode !== 0) {
1290
+ return {
1291
+ ...observed,
1292
+ outcome: "failed",
1293
+ candidateHead,
1294
+ quarantineRef: input.quarantineRef,
1295
+ rebaseCount,
1296
+ error: upload.stderr || upload.stdout || "Workspace candidate upload failed",
1297
+ gitStatus: gitStatus(input)
1298
+ };
1299
+ }
1300
+ assertPublishedCanonicalCheckoutTree(input, candidateHead, sampledCanonicalCheckoutTreeHash);
1301
+ mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
1282
1302
  return {
1283
1303
  ...observed,
1284
- outcome: "failed",
1285
- candidateHead: candidateHead ?? void 0,
1304
+ outcome: "candidate_ready",
1305
+ expectedHead,
1306
+ candidateHead,
1307
+ quarantineRef: input.quarantineRef,
1286
1308
  rebaseCount,
1287
- error: "Workspace push did not converge after three attempts",
1288
1309
  gitStatus: gitStatus(input)
1289
1310
  };
1290
1311
  } catch (error) {
@@ -1327,9 +1348,10 @@ function calculateWorkspaceDiffFingerprint(rawInput) {
1327
1348
  return createHash("sha256").update(JSON.stringify({ stagedTree, localHead, remoteHead })).digest("hex");
1328
1349
  }
1329
1350
  class WorkspaceSyncSingleFlight {
1351
+ mutationGate = new WorkspaceMutationGate();
1330
1352
  queue = Promise.resolve();
1331
1353
  enqueue(task) {
1332
- const queued = this.queue.then(task, task);
1354
+ const queued = this.mutationGate.runSync(task);
1333
1355
  this.queue = queued.then(
1334
1356
  () => void 0,
1335
1357
  () => void 0
@@ -1348,6 +1370,12 @@ class WorkspaceSyncSingleFlight {
1348
1370
  fingerprintPrepared(prepare) {
1349
1371
  return this.enqueue(() => calculateWorkspaceDiffFingerprint(prepare()));
1350
1372
  }
1373
+ runMutation(operation) {
1374
+ return this.mutationGate.runMutation(operation);
1375
+ }
1376
+ acquireMutation() {
1377
+ return this.mutationGate.acquireMutation();
1378
+ }
1351
1379
  afterCurrent() {
1352
1380
  return this.queue;
1353
1381
  }
@@ -1355,10 +1383,12 @@ class WorkspaceSyncSingleFlight {
1355
1383
  export {
1356
1384
  MAX_WORKSPACE_SYNC_DIFF_BYTES,
1357
1385
  WORKSPACE_BRANCH,
1386
+ WORKSPACE_INTENT_REF_PREFIX,
1358
1387
  WORKSPACE_NATIVE_PLANS_RELATIVE_PATH,
1359
1388
  WORKSPACE_PERIODIC_SCAN_INTERVAL_MS,
1360
1389
  WorkspaceSyncSingleFlight,
1361
1390
  assertCanonicalCheckoutIndexMatchesWorktree,
1391
+ assertWorkspaceQuarantineRef,
1362
1392
  calculateWorkspaceDiffFingerprint,
1363
1393
  encodeWorkspaceBranch,
1364
1394
  mirrorShadowWorkspaceToVisible,
@@ -1,3 +1,10 @@
1
1
  export declare const WORKER_HEARTBEAT_INTERVAL_MS = 15000;
2
2
  export declare const WORKER_HEARTBEAT_TIMEOUT_MS = 45000;
3
3
  export declare function hasWorkerHeartbeatTimedOut(lastHeartbeatAt: number | null, now?: number, timeoutMs?: number): boolean;
4
+ export type WorkerHeartbeatBusyGrace = {
5
+ heartbeatAt: number;
6
+ expiresAt: number;
7
+ };
8
+ /** Grant one bounded grace period for one actual server-heartbeat generation. */
9
+ export declare function grantWorkerHeartbeatBusyGrace(lastHeartbeatAt: number | null, currentGrace: WorkerHeartbeatBusyGrace | null, now?: number, timeoutMs?: number, graceMs?: number): WorkerHeartbeatBusyGrace | null;
10
+ export declare function hasWorkerHeartbeatTimedOutWithBusyGrace(lastHeartbeatAt: number | null, busyGrace: WorkerHeartbeatBusyGrace | null, now?: number, timeoutMs?: number): boolean;
@@ -0,0 +1,8 @@
1
+ export type WorkspaceIncidentUpdate = {
2
+ incidentId: string | null;
3
+ status: "remediating" | "waiting_for_worker" | "resolved" | "confirmed" | "reset" | null;
4
+ };
5
+ export declare function applyWorkspaceIncidentUpdate(currentIncidentId: string | null, update: WorkspaceIncidentUpdate): string | null;
6
+ export declare function releasePendingWorkspaceHead(previousIncidentId: string | null, currentIncidentId: string | null, terminalIncidentId?: string | null): {
7
+ syncHead: string | null;
8
+ };
@@ -0,0 +1,19 @@
1
+ export type WorkspaceMutationLease = () => void;
2
+ /**
3
+ * Coordinates the visible workspace with its disposable sync shadow.
4
+ *
5
+ * Ordinary worktree activity may overlap other ordinary activity, but a sync
6
+ * gets exclusive access. Once a sync is queued, later mutations wait behind it
7
+ * so a busy shell cannot starve canonical publication indefinitely.
8
+ */
9
+ export declare class WorkspaceMutationGate {
10
+ private activeMutations;
11
+ private syncActive;
12
+ private readonly waiters;
13
+ acquireMutation(): Promise<WorkspaceMutationLease>;
14
+ runMutation<T>(operation: () => Promise<T> | T): Promise<T>;
15
+ runSync<T>(operation: () => Promise<T> | T): Promise<T>;
16
+ private drain;
17
+ private releaseMutation;
18
+ private releaseSync;
19
+ }
@@ -1,5 +1,7 @@
1
1
  import { type CheckoutPathSegments } from "./managed-paths";
2
+ import { type WorkspaceMutationLease } from "./workspace-mutation-gate";
2
3
  export declare const WORKSPACE_BRANCH = "main";
4
+ export declare const WORKSPACE_INTENT_REF_PREFIX = "refs/r5d/workspace-intents/";
3
5
  export declare const MAX_WORKSPACE_SYNC_DIFF_BYTES: number;
4
6
  export declare const WORKSPACE_PERIODIC_SCAN_INTERVAL_MS = 5000;
5
7
  export type WorkspaceProjectManifestEntry = {
@@ -30,7 +32,7 @@ export type WorkspaceSyncTrigger = {
30
32
  canonicalCheckoutOnly?: boolean;
31
33
  detail?: string;
32
34
  };
33
- export type WorkspaceSyncOutcome = "no_change" | "published" | "updated" | "conflict_reset" | "large_diff_blocked" | "reset" | "failed";
35
+ export type WorkspaceSyncOutcome = "no_change" | "candidate_ready" | "published" | "updated" | "conflict_reset" | "large_diff_blocked" | "reset" | "failed";
34
36
  export type WorkspaceSyncResult = {
35
37
  type: "workspace_sync";
36
38
  attemptId: string;
@@ -38,7 +40,9 @@ export type WorkspaceSyncResult = {
38
40
  trigger: WorkspaceSyncTrigger;
39
41
  outcome: WorkspaceSyncOutcome;
40
42
  startingHead: string | null;
43
+ expectedHead?: string | null;
41
44
  candidateHead?: string;
45
+ quarantineRef?: string;
42
46
  publishedHead?: string;
43
47
  rebaseCount: number;
44
48
  diffSizeBytes: number;
@@ -60,6 +64,11 @@ export type WorkspaceSyncInput = {
60
64
  shadowRoot: string;
61
65
  projects: WorkspaceProjectManifestEntry[];
62
66
  trigger: WorkspaceSyncTrigger;
67
+ /**
68
+ * Server-issued, attempt-scoped destination for candidate object uploads.
69
+ * Workers never publish the canonical workspace branch directly.
70
+ */
71
+ quarantineRef?: string;
63
72
  confirmedLargeDiff?: boolean;
64
73
  confirmationReason?: string;
65
74
  resetToCanonical?: boolean;
@@ -91,15 +100,19 @@ type GitlinkProjection = {
91
100
  export declare function assertCanonicalCheckoutIndexMatchesWorktree(checkoutPath: string): void;
92
101
  export declare function mirrorVisibleWorkspaceToShadow(input: WorkspaceSyncInput, excludedCheckouts?: ReadonlySet<string>): GitlinkProjection;
93
102
  export declare function mirrorShadowWorkspaceToVisible(input: WorkspaceSyncInput, opaqueWorkspaceRoots?: readonly string[]): void;
103
+ export declare function assertWorkspaceQuarantineRef(quarantineRef: string): void;
94
104
  export declare function synchronizeWorkspace(rawInput: WorkspaceSyncInput): Promise<WorkspaceSyncResult>;
95
105
  export declare function calculateWorkspaceDiffFingerprint(rawInput: WorkspaceSyncInput): string;
96
106
  export declare class WorkspaceSyncSingleFlight {
107
+ private readonly mutationGate;
97
108
  private queue;
98
109
  private enqueue;
99
110
  run(input: WorkspaceSyncInput): Promise<WorkspaceSyncResult>;
100
111
  runPrepared(prepare: () => WorkspaceSyncInput): Promise<WorkspaceSyncResult>;
101
112
  fingerprint(input: WorkspaceSyncInput): Promise<string>;
102
113
  fingerprintPrepared(prepare: () => WorkspaceSyncInput): Promise<string>;
114
+ runMutation<T>(operation: () => Promise<T> | T): Promise<T>;
115
+ acquireMutation(): Promise<WorkspaceMutationLease>;
103
116
  afterCurrent(): Promise<void>;
104
117
  }
105
118
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.53",
3
+ "version": "0.0.55",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/main.cjs",
6
6
  "module": "./dist/mjs/main.mjs",