@ricsam/r5d-worker 0.0.101 → 0.0.103

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.
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.101",
3
+ "version": "0.0.103",
4
4
  "type": "module"
5
5
  }
@@ -1,16 +1,56 @@
1
- const PROJECT_MIRROR_FETCH_BACKSTOP_MS = 10 * 6e4;
2
1
  function projectMirrorFetchDecision(input) {
3
2
  if (!input.skippingEnabled) return { fetch: true, reason: "skipping_disabled" };
4
3
  if (input.advertisedToken === void 0) return { fetch: true, reason: "no_advertised_token" };
5
4
  if (input.fetchedToken === void 0) return { fetch: true, reason: "never_fetched" };
6
5
  if (input.advertisedToken !== input.fetchedToken) return { fetch: true, reason: "token_changed" };
7
- const backstopMs = input.backstopMs ?? PROJECT_MIRROR_FETCH_BACKSTOP_MS;
8
- if (input.lastFetchAtMs === void 0 || input.nowMs - input.lastFetchAtMs >= backstopMs) {
9
- return { fetch: true, reason: "backstop_elapsed" };
10
- }
11
6
  return { fetch: false, reason: "token_match" };
12
7
  }
8
+ function planProjectMirrorObservationFetch(input) {
9
+ if (!input.skippingEnabled) return { scope: "project", reason: "skipping_disabled" };
10
+ if (input.advertisedMountTokens === void 0) {
11
+ const decision = projectMirrorFetchDecision({
12
+ advertisedToken: input.advertisedProjectToken,
13
+ fetchedToken: input.fetchedProjectToken,
14
+ skippingEnabled: true
15
+ });
16
+ return decision.fetch ? { scope: "project", reason: decision.reason } : { scope: "none" };
17
+ }
18
+ const mounts = [];
19
+ for (const branchName of [...input.branchNames].sort()) {
20
+ const decision = projectMirrorFetchDecision({
21
+ advertisedToken: input.advertisedMountTokens.get(branchName),
22
+ fetchedToken: input.fetchedMountTokens.get(branchName),
23
+ skippingEnabled: true
24
+ });
25
+ if (decision.fetch) mounts.push({ branchName, reason: decision.reason });
26
+ }
27
+ return mounts.length === 0 ? { scope: "none" } : { scope: "mounts", mounts };
28
+ }
29
+ const TOKEN_RE = /^[0-9a-f]{64}$/;
30
+ const GIT_OBJECT_NAME_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
31
+ function parseProjectMirrorRefsTokensMessage(message) {
32
+ const projectTokens = /* @__PURE__ */ new Map();
33
+ if (message.tokens && typeof message.tokens === "object") {
34
+ for (const [projectId, token] of Object.entries(message.tokens)) {
35
+ if (typeof token === "string" && TOKEN_RE.test(token)) projectTokens.set(projectId, token);
36
+ }
37
+ }
38
+ const mountTokens = /* @__PURE__ */ new Map();
39
+ if (message.mountTokens && typeof message.mountTokens === "object") {
40
+ for (const [projectId, tokensByBranch] of Object.entries(message.mountTokens)) {
41
+ if (!tokensByBranch || typeof tokensByBranch !== "object") continue;
42
+ const perMount = /* @__PURE__ */ new Map();
43
+ for (const [branchName, token] of Object.entries(tokensByBranch)) {
44
+ if (typeof token === "string" && TOKEN_RE.test(token)) perMount.set(branchName, token);
45
+ }
46
+ mountTokens.set(projectId, perMount);
47
+ }
48
+ }
49
+ const workspaceHead = typeof message.workspaceHead === "string" && GIT_OBJECT_NAME_RE.test(message.workspaceHead) ? message.workspaceHead : void 0;
50
+ return { projectTokens, mountTokens, workspaceHead };
51
+ }
13
52
  export {
14
- PROJECT_MIRROR_FETCH_BACKSTOP_MS,
53
+ parseProjectMirrorRefsTokensMessage,
54
+ planProjectMirrorObservationFetch,
15
55
  projectMirrorFetchDecision
16
56
  };
@@ -1,10 +1,24 @@
1
1
  import { createHash } from "node:crypto";
2
+ const PROJECT_MIRROR_REFS_TOKEN_NAMESPACES = ["refs/heads/", "refs/r5d/server-synced/"];
2
3
  const PROJECT_MIRROR_REF_PREFIX = "refs/r5d/mirror/";
3
4
  const PROJECT_SERVER_SYNCED_REF_PREFIX = "refs/r5d/server-synced/";
5
+ const PROJECT_MIRROR_REF_LISTING_FORMAT = "--format=%(objectname) %(refname)";
6
+ const GIT_OBJECT_NAME_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
4
7
  function hashProjectMirrorRefs(refs) {
5
8
  const lines = refs.map(({ objectName, refname }) => `${objectName} ${refname}`).sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
6
9
  return createHash("sha256").update(lines.join("\n")).digest("hex");
7
10
  }
11
+ function parseProjectMirrorRefListing(output, options = {}) {
12
+ return output.split(/\r?\n/).filter(Boolean).map((row) => {
13
+ const separator = row.indexOf(" ");
14
+ const objectName = separator < 0 ? "" : row.slice(0, separator);
15
+ const refname = separator < 0 ? "" : row.slice(separator + 1);
16
+ if (!GIT_OBJECT_NAME_RE.test(objectName) || refname.length === 0 || options.namespaces !== void 0 && !options.namespaces.some((prefix) => refname.startsWith(prefix))) {
17
+ throw new Error(`Invalid ref listing row: ${row}`);
18
+ }
19
+ return { refname, objectName };
20
+ });
21
+ }
8
22
  function canonicalizeLocalProjectMirrorRefs(refs) {
9
23
  const canonical = [];
10
24
  for (const { refname, objectName } of refs) {
@@ -16,9 +30,24 @@ function canonicalizeLocalProjectMirrorRefs(refs) {
16
30
  }
17
31
  return canonical;
18
32
  }
33
+ function projectMirrorMountRefs(refs, mount) {
34
+ const headRef = `refs/heads/${mount.branchName}`;
35
+ const handoffPrefix = `${PROJECT_SERVER_SYNCED_REF_PREFIX}${mount.branchId}/`;
36
+ return refs.filter(({ refname }) => refname === headRef || refname.startsWith(handoffPrefix));
37
+ }
38
+ function projectMirrorRefsTokens(refs, mounts) {
39
+ const mountTokens = /* @__PURE__ */ new Map();
40
+ for (const mount of mounts) mountTokens.set(mount.branchName, hashProjectMirrorRefs(projectMirrorMountRefs(refs, mount)));
41
+ return { project: hashProjectMirrorRefs(refs), mounts: mountTokens };
42
+ }
19
43
  export {
44
+ PROJECT_MIRROR_REFS_TOKEN_NAMESPACES,
45
+ PROJECT_MIRROR_REF_LISTING_FORMAT,
20
46
  PROJECT_MIRROR_REF_PREFIX,
21
47
  PROJECT_SERVER_SYNCED_REF_PREFIX,
22
48
  canonicalizeLocalProjectMirrorRefs,
23
- hashProjectMirrorRefs
49
+ hashProjectMirrorRefs,
50
+ parseProjectMirrorRefListing,
51
+ projectMirrorMountRefs,
52
+ projectMirrorRefsTokens
24
53
  };
@@ -6,9 +6,11 @@ import { gitCredentialUsernameConfigKey, gitTransportSecurityArgs, workerGitProc
6
6
  import { validateManagedBranchName } from "./managed-paths.mjs";
7
7
  import {
8
8
  canonicalizeLocalProjectMirrorRefs,
9
- hashProjectMirrorRefs,
9
+ parseProjectMirrorRefListing,
10
+ PROJECT_MIRROR_REF_LISTING_FORMAT,
10
11
  PROJECT_MIRROR_REF_PREFIX,
11
- PROJECT_SERVER_SYNCED_REF_PREFIX
12
+ PROJECT_SERVER_SYNCED_REF_PREFIX,
13
+ projectMirrorRefsTokens
12
14
  } from "./project-mirror-refs-token.mjs";
13
15
  import { mirrorWorkingTree } from "./working-tree-mirror.mjs";
14
16
  const PROJECT_WORKTREE_SNAPSHOT_PREFIX = "r5d-project-worktrees-";
@@ -1125,8 +1127,12 @@ function createLinkedProjectBranch(input) {
1125
1127
  );
1126
1128
  configureProjectBranchTracking(targetPath, input.branchName, input.branchName);
1127
1129
  assertNoSymlinkBranchPathComponents(input.projectRoot, targetPath);
1128
- git(targetPath, ["read-tree", sourceHead], `initialize linked worktree index ${input.branchName}`);
1129
- mirrorWorkingTree({ sourceRoot: sourcePath, targetRoot: targetPath, sourceMode: "git", deletionMode: "git" });
1130
+ if (input.workingTree === "carry") {
1131
+ git(targetPath, ["read-tree", sourceHead], `initialize linked worktree index ${input.branchName}`);
1132
+ mirrorWorkingTree({ sourceRoot: sourcePath, targetRoot: targetPath, sourceMode: "git", deletionMode: "git" });
1133
+ } else {
1134
+ git(targetPath, ["reset", "--hard", sourceHead], `check out linked worktree ${input.branchName}`);
1135
+ }
1130
1136
  return { branchPath: targetPath, baseCommitHash: sourceHead };
1131
1137
  } catch (error) {
1132
1138
  if (branchCreated) {
@@ -1316,24 +1322,46 @@ function assertProjectMirrorHeadsPushed(results) {
1316
1322
  throw new Error(`Hidden project mirror publication skipped ${skipped.branchName}${skipped.reason ? ` (${skipped.reason})` : ""}`);
1317
1323
  }
1318
1324
  }
1325
+ const PROJECT_MIRROR_FETCH_REFSPECS = ["+refs/heads/*:refs/r5d/mirror/*", "+refs/r5d/server-synced/*:refs/r5d/server-synced/*"];
1326
+ function projectMirrorMountFetchRefspecs(branchName, branchId) {
1327
+ validateManagedBranchName(branchName);
1328
+ if (branchId !== void 0 && !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(branchId)) throw new Error(`Invalid branch id for ${branchName}`);
1329
+ return [
1330
+ `+refs/heads/${branchName}:refs/r5d/mirror/${branchName}`,
1331
+ branchId === void 0 ? "+refs/r5d/server-synced/*:refs/r5d/server-synced/*" : `+refs/r5d/server-synced/${branchId}/*:refs/r5d/server-synced/${branchId}/*`
1332
+ ];
1333
+ }
1334
+ async function fetchProjectMirrorRefs(input) {
1335
+ const fetch = (refspecs) => gitResultAsync(
1336
+ input.primaryPath,
1337
+ [
1338
+ ...gitTransportSecurityArgs(input.mirrorUrl, input.credentialHelper, input.credentialUsername),
1339
+ "fetch",
1340
+ "--no-recurse-submodules",
1341
+ "--prune",
1342
+ input.mirrorUrl,
1343
+ ...refspecs
1344
+ ],
1345
+ PROJECT_GIT_TRANSPORT_TIMEOUT_MS
1346
+ );
1347
+ if (input.fetchOnlyBranches && input.fetchOnlyBranches.size > 0) {
1348
+ const refspecs = [...input.fetchOnlyBranches].sort().flatMap((branchName) => projectMirrorMountFetchRefspecs(branchName, input.branchIds?.get(branchName)));
1349
+ const scoped = await fetch(refspecs);
1350
+ if (scoped.exitCode === 0) return;
1351
+ if (!/(couldn't find remote ref|no such ref)/i.test(`${scoped.stderr}
1352
+ ${scoped.stdout}`)) {
1353
+ throw new Error(`Refresh project head mirror: ${scoped.stderr || scoped.stdout || `git exited ${scoped.exitCode}`}`);
1354
+ }
1355
+ }
1356
+ const whole = await fetch(PROJECT_MIRROR_FETCH_REFSPECS);
1357
+ if (whole.exitCode !== 0) {
1358
+ throw new Error(`Refresh project head mirror: ${whole.stderr || whole.stdout || `git exited ${whole.exitCode}`}`);
1359
+ }
1360
+ }
1319
1361
  async function observeProjectMirrorHeads(input) {
1320
1362
  const primaryPath = branchPath(input.projectRoot, input.primaryBranchName);
1321
- if (input.refreshRemote !== false) {
1322
- const fetch = await gitResultAsync(
1323
- primaryPath,
1324
- [
1325
- ...gitTransportSecurityArgs(input.mirrorUrl, input.credentialHelper, input.credentialUsername),
1326
- "fetch",
1327
- "--no-recurse-submodules",
1328
- "--prune",
1329
- input.mirrorUrl,
1330
- "+refs/heads/*:refs/r5d/mirror/*",
1331
- "+refs/r5d/server-synced/*:refs/r5d/server-synced/*"
1332
- ],
1333
- PROJECT_GIT_TRANSPORT_TIMEOUT_MS
1334
- );
1335
- if (fetch.exitCode !== 0)
1336
- throw new Error(`Refresh project head mirror: ${fetch.stderr || fetch.stdout || `git exited ${fetch.exitCode}`}`);
1363
+ if (input.refreshRemote !== false && !(input.fetchOnlyBranches && input.fetchOnlyBranches.size === 0)) {
1364
+ await fetchProjectMirrorRefs({ ...input, primaryPath });
1337
1365
  }
1338
1366
  const observations = [];
1339
1367
  for (const branchName of [...input.branchNames].sort()) {
@@ -1380,23 +1408,14 @@ async function observeProjectMirrorHeads(input) {
1380
1408
  }
1381
1409
  return observations;
1382
1410
  }
1383
- async function computeLocalProjectMirrorRefsToken(input) {
1411
+ async function computeLocalProjectMirrorRefsTokens(input) {
1384
1412
  const primaryPath = branchPath(input.projectRoot, input.primaryBranchName);
1385
1413
  const rows = await gitAsync(
1386
1414
  primaryPath,
1387
- ["for-each-ref", "--format=%(objectname) %(refname)", PROJECT_MIRROR_REF_PREFIX, PROJECT_SERVER_SYNCED_REF_PREFIX],
1415
+ ["for-each-ref", PROJECT_MIRROR_REF_LISTING_FORMAT, PROJECT_MIRROR_REF_PREFIX, PROJECT_SERVER_SYNCED_REF_PREFIX],
1388
1416
  "list cached project mirror refs"
1389
1417
  );
1390
- const refs = rows.split(/\r?\n/).filter(Boolean).map((row) => {
1391
- const separator = row.indexOf(" ");
1392
- const objectName = separator < 0 ? "" : row.slice(0, separator);
1393
- const refname = separator < 0 ? "" : row.slice(separator + 1);
1394
- if (!/^[0-9a-f]{40,64}$/.test(objectName) || refname.length === 0) {
1395
- throw new Error(`Invalid cached mirror ref listing row: ${row}`);
1396
- }
1397
- return { refname, objectName };
1398
- });
1399
- return hashProjectMirrorRefs(canonicalizeLocalProjectMirrorRefs(refs));
1418
+ return projectMirrorRefsTokens(canonicalizeLocalProjectMirrorRefs(parseProjectMirrorRefListing(rows)), input.mounts);
1400
1419
  }
1401
1420
  async function applyObservedProjectMirrorHeads(input) {
1402
1421
  const results = [];
@@ -1479,7 +1498,7 @@ export {
1479
1498
  ProjectBranchCreationRollbackIncompleteError,
1480
1499
  applyObservedProjectMirrorHeads,
1481
1500
  assertProjectMirrorHeadsPushed,
1482
- computeLocalProjectMirrorRefsToken,
1501
+ computeLocalProjectMirrorRefsTokens,
1483
1502
  createLinkedProjectBranch,
1484
1503
  createOrRetryLinkedProjectBranch,
1485
1504
  deleteLinkedProjectBranch,
@@ -1352,6 +1352,19 @@ ${result.stdout}`;
1352
1352
  }
1353
1353
  return revParse(workspacePath, `refs/remotes/origin/${WORKSPACE_GIT_BRANCH}`);
1354
1354
  }
1355
+ async function resolveWorkspaceRemoteHead(workspacePath, input) {
1356
+ const advertised = input.advertisedRemoteHead;
1357
+ if (advertised && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(advertised)) {
1358
+ const tracked = await revParseAsync(workspacePath, `refs/remotes/origin/${WORKSPACE_GIT_BRANCH}`);
1359
+ if (tracked === advertised && await tryGitAsync(workspacePath, ["cat-file", "-e", `${tracked}^{commit}`])) {
1360
+ return { head: tracked, fetched: false };
1361
+ }
1362
+ }
1363
+ return {
1364
+ head: await fetchWorkspaceHead(workspacePath, input.remoteUrl, input.credentialHelper, input.credentialUsername),
1365
+ fetched: true
1366
+ };
1367
+ }
1355
1368
  async function ensureWorkspaceGitClone(input) {
1356
1369
  const workspacePath = path.resolve(input.workspacePath);
1357
1370
  const workspaceParent = path.dirname(workspacePath);
@@ -1399,7 +1412,8 @@ async function ensureWorkspaceGitClone(input) {
1399
1412
  if (!await revParseAsync(workspacePath, WORKSPACE_GIT_INTEGRATED_REF) && previousRemoteHead && localHeadBeforeFetch && await tryGitAsync(workspacePath, ["merge-base", "--is-ancestor", previousRemoteHead, localHeadBeforeFetch])) {
1400
1413
  await updateIntegratedWorkspaceHead(workspacePath, previousRemoteHead);
1401
1414
  }
1402
- const remoteHead = await fetchWorkspaceHead(workspacePath, input.remoteUrl, input.credentialHelper, input.credentialUsername);
1415
+ const resolvedRemoteHead = await resolveWorkspaceRemoteHead(workspacePath, input);
1416
+ const remoteHead = resolvedRemoteHead.head;
1403
1417
  let localHead = await revParseAsync(workspacePath, "HEAD");
1404
1418
  if (!localHead && remoteHead) {
1405
1419
  await runWorkspaceCheckoutTransition(workspacePath, async () => {
@@ -1420,7 +1434,7 @@ async function ensureWorkspaceGitClone(input) {
1420
1434
  await updateIntegratedWorkspaceHead(workspacePath, remoteHead);
1421
1435
  localHead = remoteHead;
1422
1436
  }
1423
- return { localHead, remoteHead };
1437
+ return { localHead, remoteHead, remoteHeadFetched: resolvedRemoteHead.fetched };
1424
1438
  }
1425
1439
  function activeMounts(mounts) {
1426
1440
  const active = [];
@@ -2069,6 +2083,11 @@ async function synchronizeWithFetchedHead(input) {
2069
2083
  return rebased.ok ? { kind: "ready", updated: false, rebased: true } : { kind: "conflict", conflictPaths: rebased.conflictPaths, refs: rebased.refs, error: rebased.error };
2070
2084
  }
2071
2085
  async function synchronizeWorkspaceGit(input) {
2086
+ const remoteHeadFetches = { count: 0 };
2087
+ const result = await runWorkspaceGitSynchronization(input, remoteHeadFetches);
2088
+ return { ...result, remoteHeadFetches: remoteHeadFetches.count };
2089
+ }
2090
+ async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
2072
2091
  const workspacePath = path.resolve(input.workspacePath);
2073
2092
  const attemptId = input.attemptId ?? crypto.randomUUID();
2074
2093
  const maxDiffBytes = input.maxDiffBytes ?? MAX_WORKSPACE_GIT_DIFF_BYTES;
@@ -2077,6 +2096,7 @@ async function synchronizeWorkspaceGit(input) {
2077
2096
  const requiredAncestorHeads = requiredWorkspaceAncestorHeads(input.requiredAncestorHeads);
2078
2097
  const preserveResolutionInProgress = input.skipMountMirror === true;
2079
2098
  const initial = await ensureWorkspaceGitClone({ ...input, workspacePath, preserveResolutionInProgress });
2099
+ if (initial.remoteHeadFetched) remoteHeadFetches.count += 1;
2080
2100
  assertWorkspaceRemediationAncestry({
2081
2101
  workspacePath,
2082
2102
  currentHead: initial.localHead,
@@ -2124,11 +2144,22 @@ async function synchronizeWorkspaceGit(input) {
2124
2144
  const classifyMounts = (activeCandidates, additionalSkippedIds = []) => {
2125
2145
  refreshCycleSkippedMounts();
2126
2146
  const skippedIds = /* @__PURE__ */ new Set([...cycleSkippedMountIds, ...projectionSkippedIds, ...additionalSkippedIds]);
2147
+ const activeMountIds = [...new Set(activeCandidates.filter(({ id }) => !skippedIds.has(id)).map(({ id }) => id))].sort();
2127
2148
  return {
2128
- activeMountIds: [...new Set(activeCandidates.filter(({ id }) => !skippedIds.has(id)).map(({ id }) => id))].sort(),
2129
- skippedMountIds: [...skippedIds].sort()
2149
+ activeMountIds,
2150
+ skippedMountIds: [...skippedIds].sort(),
2151
+ // An active mount's token is unchanged since capture (a moved token
2152
+ // lands it in cycleSkippedMountIds above), so this is the token its
2153
+ // projected bytes belong to.
2154
+ projectedMutationTokens: Object.fromEntries(
2155
+ activeMountIds.flatMap((id) => {
2156
+ const token = projectionMutationTokens.get(id);
2157
+ return token === void 0 ? [] : [[id, token]];
2158
+ })
2159
+ )
2130
2160
  };
2131
2161
  };
2162
+ const mountProjectionIsCurrent = (mount) => mount.lastProjectedMutationToken !== void 0 && !mount.deleteWhenSourceMissing && mount.preserveLocalOnHydrationBasisChange !== true && projectionBasisHead !== null && workspaceHydrationReceiptMountBasisHead(projectionBasisReceipt, mount) === projectionBasisHead && projectionMutationTokens.get(mount.id) === mount.lastProjectedMutationToken && mount.mutationToken?.() === mount.lastProjectedMutationToken;
2132
2163
  if (!input.skipMountMirror) {
2133
2164
  const mergeProjectionEnabled = process.env.R5D_WORKSPACE_MERGE_PROJECTION !== "0";
2134
2165
  const mergeSupport = mergeProjectionEnabled ? workspaceMergeProjectionSupport() : { supported: false, error: "Workspace merge projection is disabled by R5D_WORKSPACE_MERGE_PROJECTION=0" };
@@ -2267,6 +2298,7 @@ async function synchronizeWorkspaceGit(input) {
2267
2298
  }
2268
2299
  };
2269
2300
  const projectedFastMounts = [];
2301
+ const currentProjectionMounts = [];
2270
2302
  if (!input.skipMountMirror) {
2271
2303
  beginWorkspaceCheckoutTransition(workspacePath);
2272
2304
  try {
@@ -2278,6 +2310,10 @@ async function synchronizeWorkspaceGit(input) {
2278
2310
  cycleSkippedMountIds.add(mount.id);
2279
2311
  continue;
2280
2312
  }
2313
+ if (mountProjectionIsCurrent(mount)) {
2314
+ currentProjectionMounts.push(mount);
2315
+ continue;
2316
+ }
2281
2317
  projectedGitlinks.push(...mirrorMountsToWorkspace(workspacePath, [mount]));
2282
2318
  projectedFastMounts.push(mount);
2283
2319
  await yieldToWorkerEventLoop();
@@ -2311,7 +2347,7 @@ async function synchronizeWorkspaceGit(input) {
2311
2347
  const targetProjectionReceipt = receiptWithMountsAtHead(
2312
2348
  projectionBasisReceipt,
2313
2349
  projectedHead,
2314
- projectedFastMounts,
2350
+ [...projectedFastMounts, ...currentProjectionMounts],
2315
2351
  allProjectionReceiptMounts
2316
2352
  );
2317
2353
  const currentProjectionReceipt = await readHydratedWorkspaceReceiptAsync(workspacePath);
@@ -2324,7 +2360,9 @@ async function synchronizeWorkspaceGit(input) {
2324
2360
  }
2325
2361
  await completeWorkspaceCheckoutTransition(workspacePath);
2326
2362
  }
2327
- let remoteHead = await fetchWorkspaceHead(workspacePath, input.remoteUrl, input.credentialHelper, input.credentialUsername);
2363
+ const prePublishRemoteHead = await resolveWorkspaceRemoteHead(workspacePath, input);
2364
+ if (prePublishRemoteHead.fetched) remoteHeadFetches.count += 1;
2365
+ let remoteHead = prePublishRemoteHead.head;
2328
2366
  let rebaseCount = 0;
2329
2367
  let updated = false;
2330
2368
  for (let pushAttempt = 0; pushAttempt < maxPushAttempts; pushAttempt += 1) {
@@ -2481,6 +2519,7 @@ ${push.stdout}`)) {
2481
2519
  throw new Error(`Push workspace main: ${push.stderr || push.stdout || `git exited ${push.exitCode}`}`);
2482
2520
  }
2483
2521
  remoteHead = await fetchWorkspaceHead(workspacePath, input.remoteUrl, input.credentialHelper, input.credentialUsername);
2522
+ remoteHeadFetches.count += 1;
2484
2523
  }
2485
2524
  throw new Error(`Workspace push did not converge after ${maxPushAttempts} attempts`);
2486
2525
  }
@@ -27,8 +27,43 @@ class WorkspaceMutationGate {
27
27
  release();
28
28
  }
29
29
  }
30
+ /** A worktree create/delete lease; see the branch lane in the class comment. */
31
+ acquireBranchMutation() {
32
+ return new Promise((resolve) => {
33
+ this.waiters.push({ kind: "branch", resolve });
34
+ this.drain();
35
+ });
36
+ }
37
+ async runBranchMutation(operation) {
38
+ const release = await this.acquireBranchMutation();
39
+ try {
40
+ return await operation();
41
+ } finally {
42
+ release();
43
+ }
44
+ }
45
+ /**
46
+ * Operations a branch mutation requested now would wait for: the running
47
+ * sync, if any, plus branch mutations queued behind it. Zero means it runs
48
+ * at once. Ordinary waiters are not counted because the branch lane admits
49
+ * ahead of them.
50
+ */
51
+ branchMutationQueuePosition() {
52
+ if (!this.syncActive) return 0;
53
+ return 1 + this.waiters.filter((waiter) => waiter.kind === "branch").length;
54
+ }
30
55
  drain() {
31
56
  if (this.syncActive || this.waiters.length === 0) return;
57
+ for (let index = 0; index < this.waiters.length; ) {
58
+ const waiter = this.waiters[index];
59
+ if (waiter?.kind !== "branch") {
60
+ index += 1;
61
+ continue;
62
+ }
63
+ this.waiters.splice(index, 1);
64
+ this.activeMutations += 1;
65
+ waiter.resolve(this.releaseMutation());
66
+ }
32
67
  const first = this.waiters[0];
33
68
  if (first?.kind === "sync") {
34
69
  if (this.activeMutations > 0) return;
@@ -0,0 +1,57 @@
1
+ const RETAINED_OUTCOMES = /* @__PURE__ */ new Set(["no_change", "updated", "published", "large_diff_blocked"]);
2
+ class WorkspaceProjectionLedger {
3
+ projectedTokens = /* @__PURE__ */ new Map();
4
+ lastCycle = null;
5
+ static recordKey(mount) {
6
+ return `${mount.hydrationIncarnationKey}\0${mount.id}`;
7
+ }
8
+ get lastCycleResult() {
9
+ return this.lastCycle;
10
+ }
11
+ lastProjectedToken(mount) {
12
+ return this.projectedTokens.get(WorkspaceProjectionLedger.recordKey(mount));
13
+ }
14
+ /**
15
+ * Record a completed cycle. `projectedMutationTokens` names the token each
16
+ * active mount carried when the cycle projected it; a mount absent from it
17
+ * keeps its earlier record. Any outcome that leaves the outer clone without
18
+ * a trustworthy projection clears the ledger.
19
+ */
20
+ recordCycle(input) {
21
+ if (!RETAINED_OUTCOMES.has(input.result.outcome)) {
22
+ this.reset();
23
+ return;
24
+ }
25
+ const mountsById = new Map(input.mounts.map((mount) => [mount.id, mount]));
26
+ for (const [mountId, token] of Object.entries(input.projectedMutationTokens ?? {})) {
27
+ const mount = mountsById.get(mountId);
28
+ if (mount) this.projectedTokens.set(WorkspaceProjectionLedger.recordKey(mount), token);
29
+ }
30
+ this.lastCycle = input.result;
31
+ }
32
+ reset() {
33
+ this.projectedTokens.clear();
34
+ this.lastCycle = null;
35
+ }
36
+ /**
37
+ * The previous cycle's result when every request targets a configured
38
+ * mount and no mount's token moved since its recorded projection. A request
39
+ * without a target (a workspace-rooted command may touch any checkout), a
40
+ * mount without a token (a pending deletion), or pending deletion work all
41
+ * require a real cycle.
42
+ */
43
+ answerFromLastCycle(input) {
44
+ if (!this.lastCycle || input.pendingDeletions || input.targetMountIds.length === 0) return null;
45
+ const mountIds = new Set(input.mounts.map(({ id }) => id));
46
+ if (!input.targetMountIds.every((mountId) => mountId !== null && mountIds.has(mountId))) return null;
47
+ for (const mount of input.mounts) {
48
+ if (!mount.mutationToken) return null;
49
+ const recorded = this.projectedTokens.get(WorkspaceProjectionLedger.recordKey(mount));
50
+ if (recorded === void 0 || recorded !== mount.mutationToken()) return null;
51
+ }
52
+ return this.lastCycle;
53
+ }
54
+ }
55
+ export {
56
+ WorkspaceProjectionLedger
57
+ };
@@ -0,0 +1,34 @@
1
+ class WorkspaceSyncCoalescer {
2
+ constructor(options) {
3
+ this.options = options;
4
+ }
5
+ options;
6
+ pendingBatch = null;
7
+ batchesStarted = 0;
8
+ /** Requests attached to the batch that has not started yet. */
9
+ get queuedRequestCount() {
10
+ return this.pendingBatch?.requests.length ?? 0;
11
+ }
12
+ /** Batches whose body has been entered, whether or not they ran a cycle. */
13
+ get startedBatchCount() {
14
+ return this.batchesStarted;
15
+ }
16
+ request(request) {
17
+ const queued = this.pendingBatch;
18
+ if (queued) {
19
+ queued.requests.push(request);
20
+ return queued.settlement;
21
+ }
22
+ const batch = { requests: [request], settlement: Promise.resolve() };
23
+ this.pendingBatch = batch;
24
+ batch.settlement = this.options.runExclusive(async () => {
25
+ if (this.pendingBatch === batch) this.pendingBatch = null;
26
+ this.batchesStarted += 1;
27
+ return await this.options.runBatch(batch.requests);
28
+ });
29
+ return batch.settlement;
30
+ }
31
+ }
32
+ export {
33
+ WorkspaceSyncCoalescer
34
+ };
@@ -185,6 +185,11 @@ type WorkerClientMessage = {
185
185
  requestId: string;
186
186
  result?: WorkerOperationResult;
187
187
  error?: string;
188
+ } | {
189
+ /** Immediate receipt for a branch create/delete; `queuePosition` is 0 when it runs at once. */
190
+ type: "operation_queued";
191
+ requestId: string;
192
+ queuePosition: number;
188
193
  } | {
189
194
  type: "exec_result";
190
195
  requestId: string;
@@ -290,13 +295,18 @@ type WorkerServerMessage = {
290
295
  requiredAncestorHeads?: string[];
291
296
  } | {
292
297
  /**
293
- * Content-derived tokens naming each canonical project repository's
294
- * observable ref state, keyed by project id. Replace-all: projects
295
- * absent from a message have no advertised token and observation falls
296
- * back to fetching. See `project-mirror-fetch-policy.ts`.
298
+ * Content-derived tokens naming ref state the server read from disk:
299
+ * `tokens` per canonical project repository, `mountTokens` per
300
+ * checked-out project branch (keyed by project id then branch name),
301
+ * and the current head of the hidden workspace repository. Replace-all:
302
+ * anything absent from a message has no advertised token and the
303
+ * corresponding fetch runs. Older servers send `tokens` only. See
304
+ * `project-mirror-fetch-policy.ts`.
297
305
  */
298
306
  type: "project_mirror_refs_tokens";
299
307
  tokens: Record<string, string>;
308
+ mountTokens?: Record<string, Record<string, string>>;
309
+ workspaceHead?: string;
300
310
  } | {
301
311
  type: "create_project_branch";
302
312
  requestId: string;
@@ -304,6 +314,8 @@ type WorkerServerMessage = {
304
314
  projectId: string;
305
315
  sourceBranch: string;
306
316
  targetBranch: string;
317
+ /** `carry` copies the source checkout's uncommitted changes; `clean` checks out its commit only. */
318
+ workingTree: "carry" | "clean";
307
319
  } | {
308
320
  type: "delete_project_branch";
309
321
  requestId: string;
@@ -618,14 +630,17 @@ export declare const workerPtyTestHarness: {
618
630
  removeEnvFiles: typeof removePtyEnvFiles;
619
631
  resolveEnvFileReferences: typeof resolvePtyEnvFileReferences;
620
632
  commandHasWorkspaceEffect: typeof workerCommandHasWorkspaceEffect;
633
+ targetProcessEnv: typeof targetProcessEnv;
621
634
  canonicalSyncTerminalHead: typeof canonicalSyncTerminalHead;
622
635
  ptyIsWorkspaceBusy: typeof workerPtyIsWorkspaceBusy;
623
636
  parseLinuxForegroundBusy: typeof parseLinuxPtyForegroundBusy;
624
637
  };
625
638
  export type WorkerBuiltInToolPaths = {
626
- artifactsDir?: string;
627
- plansDir?: string;
628
- activePlanFile?: string;
639
+ rootDir?: string;
640
+ sessionId?: string;
641
+ projectId?: string;
642
+ branchName?: string;
643
+ activePlanId?: string;
629
644
  };
630
645
  export declare function isArtifactEnvPath(filePath: string): boolean;
631
646
  export declare function syncSessionArtifacts(input: {
@@ -638,11 +653,11 @@ export declare function prepareArtifactEnvForShell(input: {
638
653
  baseUrl: string;
639
654
  token: string;
640
655
  sessionId?: string;
641
- artifactRoot: string;
656
+ rootDir: string;
642
657
  }): Promise<Record<string, string>>;
643
658
  export declare function preparePlanEnvForShell(input: {
644
659
  target: WorkerSessionTarget;
645
- planRoot: string;
660
+ rootDir: string;
646
661
  activePlanId?: string;
647
662
  }): Record<string, string>;
648
663
  type ResolvedWorkerFilePath = {
@@ -943,8 +958,7 @@ export declare function prepareBuiltInToolPathsForTarget(input: {
943
958
  token: string;
944
959
  sessionId?: string;
945
960
  activePlanId?: string;
946
- artifactRoot: string;
947
- planRoot: string;
961
+ rootDir: string;
948
962
  access: "read" | "write";
949
963
  }): Promise<WorkerBuiltInToolPaths | undefined>;
950
964
  export declare function readWorkerTextFile(branchPath: string, filePath: string, offset?: number, limit?: number, builtInPaths?: WorkerBuiltInToolPaths): WorkerReadFileResult;
@@ -960,8 +974,7 @@ declare function executeWriteFileOperation(input: {
960
974
  resolvedTarget: ResolvedWorkerSessionTarget;
961
975
  baseUrl: string;
962
976
  token: string;
963
- artifactRoot: string;
964
- planRoot: string;
977
+ rootDir: string;
965
978
  assertAdmission: () => void;
966
979
  }): Promise<WorkerWriteFileResult>;
967
980
  declare function executeEditFileOperation(input: {
@@ -971,8 +984,7 @@ declare function executeEditFileOperation(input: {
971
984
  resolvedTarget: ResolvedWorkerSessionTarget;
972
985
  baseUrl: string;
973
986
  token: string;
974
- artifactRoot: string;
975
- planRoot: string;
987
+ rootDir: string;
976
988
  assertAdmission: () => void;
977
989
  }): Promise<WorkerEditFileResult>;
978
990
  export declare function grepWorkerFiles(branchPath: string, input: {
@@ -992,14 +1004,14 @@ export declare function listWorkerDirectory(branchPath: string, inputPath?: stri
992
1004
  export declare function listWorkerCodeDirectory(branchPath: string, inputPath: string): WorkerCodeListResult;
993
1005
  export declare function readWorkerCodeFile(branchPath: string, inputPath: string): WorkerCodeReadResult;
994
1006
  export declare function readWorkerImageFile(branchPath: string, filePath: string, builtInPaths?: WorkerBuiltInToolPaths): WorkerViewFileBytesResult;
1007
+ declare function targetProcessEnv(rootDir: string, target: WorkerSessionTarget): Record<string, string>;
995
1008
  export declare function prepareShellEnvForTarget(input: {
996
1009
  target: WorkerSessionTarget;
997
1010
  baseUrl: string;
998
1011
  token: string;
999
1012
  sessionId?: string;
1000
1013
  activePlanId?: string;
1001
- artifactRoot: string;
1002
- planRoot: string;
1014
+ rootDir: string;
1003
1015
  }): Promise<Record<string, string>>;
1004
1016
  declare function createNodePtyBridge(options: {
1005
1017
  file: string;