@ricsam/r5dctl 0.0.72 → 0.0.73

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/README.md CHANGED
@@ -93,6 +93,8 @@ r5dctl session stop <session-id>
93
93
  r5dctl -p <project> merge fix/tests
94
94
  r5dctl -p <project> merge --worker <label> --create-conflict-worktree fix/tests
95
95
  r5dctl -p <project> merge --worker <label> --launch-conflict-resolution-agent fix/tests
96
+ r5dctl -p <project> merge --worker <label> --launch-conflict-resolution-agent --no-wait fix/tests
97
+ r5dctl -p <project> merge status <attempt-id>
96
98
  r5dctl -p <project> merge finish --worker <label> conflicts/fix-tests --summary "Resolved the conflicts"
97
99
 
98
100
  r5dctl -p <project> -b <branch> shell
@@ -115,6 +117,10 @@ Add `-f`/`--follow`/`--watch` to `session start` to attach after enqueue, or to
115
117
 
116
118
  `merge` merges a managed source worktree into its recorded parent. By default conflicts are reported without creating more work. `--create-conflict-worktree` prepares a manual conflict worktree, while `--launch-conflict-resolution-agent` starts the dedicated merge-conflict resolver; those strategies are mutually exclusive. A worker-bound shell credential supplies its own worker automatically; otherwise pass `--worker`. Finish a reviewed manual resolution with `merge finish`.
117
119
 
120
+ Merging is lockless and optimistic: nothing blocks agents from continuing to work on the involved branches. Every merge pins the branch tips it previewed and publishes through an atomic compare-and-swap, and a concurrent publication simply retries. When a resolver agent finishes after the target advanced, the platform automatically continues the merge — publishing directly if the refreshed merge is conflict-free, or launching a fresh resolver attempt (whose prompt references the superseded session's resolution) — repeating until one resolution publishes. Superseded attempts surface the follow-up attempt id in `merge status`. Only manual conflict worktrees stop at `stale`: re-run `merge` to start a fresh resolution.
121
+
122
+ The server answers merge requests as soon as the result is durably recorded; the slow per-member workspace propagation always completes asynchronously behind the response. For a clean merge that means the merged commit is already published when the command returns. For a conflict strategy the server responds with the merge attempt in a `provisioning` state and prepares the conflict worktree (and starts the resolver agent) in the background; the CLI polls the attempt until the worktree is ready — and, for the agent strategy, until the resolver agent is running — before printing the final result. Pass `--no-wait` to skip the wait and get the attempt id immediately, and use `merge status <attempt-id>` at any time to re-check a provisioning, failed, or completed attempt (a provisioning failure surfaces there as a `failed` attempt with its reason). Ctrl-C during the wait only detaches the CLI; provisioning continues server-side.
123
+
118
124
  `shell` connects to the same live worker PTY as the web Shell tab. Without `-c` it attaches the local terminal interactively. With `-c`/`--command`, it runs the supplied shell expression, streams combined PTY output, and exits with the remote command's status. Because command output uses a PTY, stdout and stderr are combined and programs may emit colors or other terminal control sequences.
119
125
 
120
126
  `ps list` shows active long-running commands managed by r5d. It does not expose the worker host's general OS process table or interactive PTYs. Global project, branch, and session options filter the list, while `--worker` selects a worker label. `ps stop` sends `SIGTERM` to the selected run. Inside an r5d-provided worker shell, the temporary credential can only list and stop runs on that shell's worker.
package/dist/cjs/cli.cjs CHANGED
@@ -42,6 +42,7 @@ __export(cli_exports, {
42
42
  getR5dctlVersion: () => getR5dctlVersion,
43
43
  getTransientDevicePollDelay: () => getTransientDevicePollDelay,
44
44
  handleAuthLogin: () => handleAuthLogin,
45
+ isMergeAttemptSettled: () => isMergeAttemptSettled,
45
46
  main: () => main,
46
47
  parseAnswerEnvRequestCommandArgs: () => parseAnswerEnvRequestCommandArgs,
47
48
  parseAnswerFlags: () => parseAnswerFlags,
@@ -96,16 +97,8 @@ var import_promises = require("node:timers/promises");
96
97
  var import_dotenv = require("dotenv");
97
98
  var import_r5d_api = require("@ricsam/r5d-api");
98
99
  var import_shell = require("./shell.cjs");
99
- const CHAT_MODES = /* @__PURE__ */ new Set([
100
- "ask",
101
- "plan",
102
- "build",
103
- "agent",
104
- "security_review",
105
- "large_diff_remediation",
106
- "merge_conflict_resolution"
107
- ]);
108
- const R5DCTL_SESSION_MODES = /* @__PURE__ */ new Set(["ask", "plan", "build", "agent"]);
100
+ const CHAT_MODES = /* @__PURE__ */ new Set(["ask", "plan", "agent", "large_diff_remediation", "merge_conflict_resolution"]);
101
+ const R5DCTL_SESSION_MODES = /* @__PURE__ */ new Set(["ask", "plan", "agent"]);
109
102
  const MODEL_TIERS = /* @__PURE__ */ new Set(["low", "medium", "high", "max"]);
110
103
  const R5DCTL_PACKAGE_NAME = "@ricsam/r5dctl";
111
104
  const CLI_GLOBAL_OPTION_HELP = [
@@ -174,10 +167,12 @@ const SESSION_RUN_HELP_TEXT = [
174
167
  ].join("\n");
175
168
  const MERGE_HELP_TEXT = [
176
169
  "Usage:",
177
- " r5dctl -p <project> merge [--worker <label>] [--create-conflict-worktree|--launch-conflict-resolution-agent] <source-worktree>",
170
+ " r5dctl -p <project> merge [--worker <label>] [--create-conflict-worktree|--launch-conflict-resolution-agent] [--no-wait] <source-worktree>",
171
+ " r5dctl -p <project> merge status <attempt-id>",
178
172
  ' r5dctl -p <project> merge finish --worker <label> [--summary "<summary>"] <conflict-worktree>',
179
173
  "",
180
174
  "Merge a managed worktree into its recorded parent worktree.",
175
+ "Conflict worktrees provision in the background; merge waits by default and --no-wait returns the attempt immediately.",
181
176
  ""
182
177
  ].join("\n");
183
178
  const SHARED_HELP_ENTRIES = [
@@ -328,9 +323,14 @@ const SHARED_HELP_ENTRIES = [
328
323
  { section: "processes", usage: "ps stop <run-id>", description: "Gracefully stop an active process with SIGTERM." },
329
324
  {
330
325
  section: "merge",
331
- usage: "-p <project> merge [--worker <label>] [--create-conflict-worktree|--launch-conflict-resolution-agent] <source-worktree>",
326
+ usage: "-p <project> merge [--worker <label>] [--create-conflict-worktree|--launch-conflict-resolution-agent] [--no-wait] <source-worktree>",
332
327
  description: "Merge a worktree into its recorded parent, with an explicit conflict strategy."
333
328
  },
329
+ {
330
+ section: "merge",
331
+ usage: "-p <project> merge status <attempt-id>",
332
+ description: "Show a merge attempt's provisioning and resolution status."
333
+ },
334
334
  {
335
335
  section: "merge",
336
336
  usage: '-p <project> merge finish --worker <label> [--summary "<summary>"] <conflict-worktree>',
@@ -567,7 +567,7 @@ function requireModel(value) {
567
567
  function requireSessionMode(value) {
568
568
  const mode = requireValue(value?.trim(), "--mode <mode> is required");
569
569
  if (!R5DCTL_SESSION_MODES.has(mode)) {
570
- throw new Error("Invalid --mode. Expected one of: ask, plan, build, agent");
570
+ throw new Error("Invalid --mode. Expected one of: ask, plan, agent");
571
571
  }
572
572
  return mode;
573
573
  }
@@ -653,12 +653,19 @@ function parseMergeCommand(options, args) {
653
653
  ...parsed2.values.get("--summary")?.trim() ? { summary: parsed2.values.get("--summary").trim() } : {}
654
654
  };
655
655
  }
656
+ if (args[0] === "status") {
657
+ const parsed2 = parseCommandOperands(args.slice(1), /* @__PURE__ */ new Set(), /* @__PURE__ */ new Map(), "merge status");
658
+ const attemptId = requireValue(parsed2.positionals[0], "Attempt id is required for `merge status`");
659
+ if (parsed2.positionals.length > 1) throw new Error(`Unexpected merge status argument: ${parsed2.positionals[1]}`);
660
+ return { kind: "status", project, attemptId };
661
+ }
656
662
  const parsed = parseCommandOperands(
657
663
  args,
658
664
  /* @__PURE__ */ new Set(["--worker"]),
659
665
  /* @__PURE__ */ new Map([
660
666
  ["--create-conflict-worktree", "--create-conflict-worktree"],
661
- ["--launch-conflict-resolution-agent", "--launch-conflict-resolution-agent"]
667
+ ["--launch-conflict-resolution-agent", "--launch-conflict-resolution-agent"],
668
+ ["--no-wait", "--no-wait"]
662
669
  ]),
663
670
  "merge"
664
671
  );
@@ -671,7 +678,14 @@ function parseMergeCommand(options, args) {
671
678
  }
672
679
  const conflictMode = createWorktree ? "worktree" : launchAgent ? "agent" : "none";
673
680
  const worker = parsed.values.get("--worker")?.trim();
674
- return { kind: "merge", project, sourceWorktree, conflictMode, ...worker ? { worker } : {} };
681
+ return {
682
+ kind: "merge",
683
+ project,
684
+ sourceWorktree,
685
+ conflictMode,
686
+ ...worker ? { worker } : {},
687
+ ...parsed.booleans.has("--no-wait") ? { noWait: true } : {}
688
+ };
675
689
  }
676
690
  function assertAuthLoginArgs(args) {
677
691
  const valueFlags = /* @__PURE__ */ new Set(["--device-name", "--worker-label"]);
@@ -1959,17 +1973,22 @@ function renderSessionRunStatus(session) {
1959
1973
  function renderWorktreeMerge(result, input) {
1960
1974
  const lines = [];
1961
1975
  appendMergeDetails(lines, result);
1976
+ const status = responseString(result, "status");
1962
1977
  const conflictWorktree = responseString(result, "conflictWorktree");
1963
1978
  const resolverSessionId = responseString(result, "resolverSessionId");
1979
+ const attemptId = responseString(result, "attemptId");
1964
1980
  const worker = responseString(result, "worker") ?? input.worker;
1965
1981
  if (resolverSessionId) lines.push("", `Resolver session: ${resolverSessionId}`, ` r5dctl session stop ${resolverSessionId}`);
1966
- if (conflictWorktree && worker && ["conflicts", "conflict_worktree_created", "resolving"].includes(responseString(result, "status") ?? "")) {
1982
+ if (conflictWorktree && worker && ["conflicts", "conflict_worktree_created", "resolving"].includes(status ?? "")) {
1967
1983
  lines.push(
1968
1984
  "",
1969
1985
  "Finish after resolving and reviewing the conflict worktree:",
1970
1986
  ` r5dctl -p ${input.project} merge finish --worker ${worker} ${conflictWorktree} --summary "<summary>"`
1971
1987
  );
1972
1988
  }
1989
+ if (attemptId && (status === "provisioning" || status === "resolving" && result.resolverAgentRunning === false)) {
1990
+ lines.push("", "Check progress:", ` r5dctl -p ${input.project} merge status ${attemptId}`);
1991
+ }
1973
1992
  return `${lines.join("\n")}
1974
1993
  `;
1975
1994
  }
@@ -2009,13 +2028,64 @@ async function dispatchSessionRunCommand(client, command) {
2009
2028
  const data = await client.sessions.stop(command.sessionId);
2010
2029
  return { data, human: renderSessionRunStatus(data) };
2011
2030
  }
2012
- async function dispatchMergeCommand(client, command) {
2031
+ const MERGE_POLL_INTERVAL_MS = 2e3;
2032
+ const MERGE_POLL_TIMEOUT_MS = 20 * 6e4;
2033
+ const MERGE_POLL_MAX_CONSECUTIVE_FAILURES = 5;
2034
+ function isMergeAttemptSettled(response) {
2035
+ const status = responseString(response, "status");
2036
+ if (!status) return true;
2037
+ if (status === "resolving") return response.resolverAgentRunning !== false;
2038
+ return status !== "provisioning";
2039
+ }
2040
+ async function pollMergeAttemptUntilSettled(client, project, attemptId, initial, options) {
2041
+ const intervalMs = options.pollIntervalMs ?? MERGE_POLL_INTERVAL_MS;
2042
+ const timeoutMs = options.pollTimeoutMs ?? MERGE_POLL_TIMEOUT_MS;
2043
+ const sleep2 = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
2044
+ const onProgress = options.onProgress ?? (() => {
2045
+ });
2046
+ let latest = initial;
2047
+ let worktreeReadyReported = latest.worktreeReady === true;
2048
+ let consecutiveFailures = 0;
2049
+ let waitedMs = 0;
2050
+ while (!isMergeAttemptSettled(latest)) {
2051
+ if (waitedMs >= timeoutMs) {
2052
+ onProgress(`Still waiting after ${Math.round(timeoutMs / 6e4)} minutes; check later with: r5dctl -p ${project} merge status ${attemptId}`);
2053
+ return latest;
2054
+ }
2055
+ await sleep2(intervalMs);
2056
+ waitedMs += intervalMs;
2057
+ try {
2058
+ latest = await client.projects.worktrees.mergeAttempt(project, attemptId);
2059
+ consecutiveFailures = 0;
2060
+ } catch (error) {
2061
+ consecutiveFailures += 1;
2062
+ if (consecutiveFailures >= MERGE_POLL_MAX_CONSECUTIVE_FAILURES) throw error;
2063
+ continue;
2064
+ }
2065
+ if (!worktreeReadyReported && latest.worktreeReady === true) {
2066
+ worktreeReadyReported = true;
2067
+ onProgress("Conflict worktree ready.");
2068
+ }
2069
+ }
2070
+ return latest;
2071
+ }
2072
+ async function dispatchMergeCommand(client, command, pollOptions = {}) {
2013
2073
  if (command.kind === "merge") {
2014
2074
  const data2 = await client.projects.worktrees.merge(command.project, command.sourceWorktree, {
2015
2075
  conflictMode: command.conflictMode,
2016
2076
  ...command.worker ? { worker: command.worker } : {}
2017
2077
  });
2018
- return { data: data2, human: renderWorktreeMerge(data2, command) };
2078
+ const attemptId = responseString(data2, "attemptId");
2079
+ if (command.noWait || !attemptId || isMergeAttemptSettled(data2)) {
2080
+ return { data: data2, human: renderWorktreeMerge(data2, command) };
2081
+ }
2082
+ pollOptions.onProgress?.(`Provisioning the conflict worktree (attempt ${attemptId})...`);
2083
+ const settled = await pollMergeAttemptUntilSettled(client, command.project, attemptId, data2, pollOptions);
2084
+ return { data: settled, human: renderWorktreeMerge(settled, command) };
2085
+ }
2086
+ if (command.kind === "status") {
2087
+ const data2 = await client.projects.worktrees.mergeAttempt(command.project, command.attemptId);
2088
+ return { data: data2, human: renderWorktreeMerge(data2, { project: command.project }) };
2019
2089
  }
2020
2090
  const data = await client.projects.worktrees.finishMerge(command.project, command.conflictWorktree, {
2021
2091
  worker: command.worker,
@@ -3284,7 +3354,18 @@ async function runCommand(argv) {
3284
3354
  });
3285
3355
  }
3286
3356
  if (execution.kind === "merge") {
3287
- const result = await dispatchMergeCommand(client, execution.command);
3357
+ if (!options.json) {
3358
+ if (execution.command.kind === "merge") {
3359
+ process.stderr.write("Merging (publishing the workspace and computing the merge)...\n");
3360
+ } else if (execution.command.kind === "finish") {
3361
+ process.stderr.write(`Finalizing ${execution.command.conflictWorktree} (publishing the resolver workspace)...
3362
+ `);
3363
+ }
3364
+ }
3365
+ const result = await dispatchMergeCommand(client, execution.command, {
3366
+ ...options.json ? {} : { onProgress: (line) => process.stderr.write(`${line}
3367
+ `) }
3368
+ });
3288
3369
  writeDataOutput(options.json, result.data, result.human);
3289
3370
  return 0;
3290
3371
  }
@@ -3360,6 +3441,7 @@ async function main(argv = process.argv.slice(2)) {
3360
3441
  getR5dctlVersion,
3361
3442
  getTransientDevicePollDelay,
3362
3443
  handleAuthLogin,
3444
+ isMergeAttemptSettled,
3363
3445
  main,
3364
3446
  parseAnswerEnvRequestCommandArgs,
3365
3447
  parseAnswerFlags,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5dctl",
3
- "version": "0.0.72",
3
+ "version": "0.0.73",
4
4
  "type": "commonjs"
5
5
  }
package/dist/mjs/cli.mjs CHANGED
@@ -11,16 +11,8 @@ import {
11
11
  R5dctlClient
12
12
  } from "@ricsam/r5d-api";
13
13
  import { runR5dctlShell } from "./shell.mjs";
14
- const CHAT_MODES = /* @__PURE__ */ new Set([
15
- "ask",
16
- "plan",
17
- "build",
18
- "agent",
19
- "security_review",
20
- "large_diff_remediation",
21
- "merge_conflict_resolution"
22
- ]);
23
- const R5DCTL_SESSION_MODES = /* @__PURE__ */ new Set(["ask", "plan", "build", "agent"]);
14
+ const CHAT_MODES = /* @__PURE__ */ new Set(["ask", "plan", "agent", "large_diff_remediation", "merge_conflict_resolution"]);
15
+ const R5DCTL_SESSION_MODES = /* @__PURE__ */ new Set(["ask", "plan", "agent"]);
24
16
  const MODEL_TIERS = /* @__PURE__ */ new Set(["low", "medium", "high", "max"]);
25
17
  const R5DCTL_PACKAGE_NAME = "@ricsam/r5dctl";
26
18
  const CLI_GLOBAL_OPTION_HELP = [
@@ -89,10 +81,12 @@ const SESSION_RUN_HELP_TEXT = [
89
81
  ].join("\n");
90
82
  const MERGE_HELP_TEXT = [
91
83
  "Usage:",
92
- " r5dctl -p <project> merge [--worker <label>] [--create-conflict-worktree|--launch-conflict-resolution-agent] <source-worktree>",
84
+ " r5dctl -p <project> merge [--worker <label>] [--create-conflict-worktree|--launch-conflict-resolution-agent] [--no-wait] <source-worktree>",
85
+ " r5dctl -p <project> merge status <attempt-id>",
93
86
  ' r5dctl -p <project> merge finish --worker <label> [--summary "<summary>"] <conflict-worktree>',
94
87
  "",
95
88
  "Merge a managed worktree into its recorded parent worktree.",
89
+ "Conflict worktrees provision in the background; merge waits by default and --no-wait returns the attempt immediately.",
96
90
  ""
97
91
  ].join("\n");
98
92
  const SHARED_HELP_ENTRIES = [
@@ -243,9 +237,14 @@ const SHARED_HELP_ENTRIES = [
243
237
  { section: "processes", usage: "ps stop <run-id>", description: "Gracefully stop an active process with SIGTERM." },
244
238
  {
245
239
  section: "merge",
246
- usage: "-p <project> merge [--worker <label>] [--create-conflict-worktree|--launch-conflict-resolution-agent] <source-worktree>",
240
+ usage: "-p <project> merge [--worker <label>] [--create-conflict-worktree|--launch-conflict-resolution-agent] [--no-wait] <source-worktree>",
247
241
  description: "Merge a worktree into its recorded parent, with an explicit conflict strategy."
248
242
  },
243
+ {
244
+ section: "merge",
245
+ usage: "-p <project> merge status <attempt-id>",
246
+ description: "Show a merge attempt's provisioning and resolution status."
247
+ },
249
248
  {
250
249
  section: "merge",
251
250
  usage: '-p <project> merge finish --worker <label> [--summary "<summary>"] <conflict-worktree>',
@@ -482,7 +481,7 @@ function requireModel(value) {
482
481
  function requireSessionMode(value) {
483
482
  const mode = requireValue(value?.trim(), "--mode <mode> is required");
484
483
  if (!R5DCTL_SESSION_MODES.has(mode)) {
485
- throw new Error("Invalid --mode. Expected one of: ask, plan, build, agent");
484
+ throw new Error("Invalid --mode. Expected one of: ask, plan, agent");
486
485
  }
487
486
  return mode;
488
487
  }
@@ -568,12 +567,19 @@ function parseMergeCommand(options, args) {
568
567
  ...parsed2.values.get("--summary")?.trim() ? { summary: parsed2.values.get("--summary").trim() } : {}
569
568
  };
570
569
  }
570
+ if (args[0] === "status") {
571
+ const parsed2 = parseCommandOperands(args.slice(1), /* @__PURE__ */ new Set(), /* @__PURE__ */ new Map(), "merge status");
572
+ const attemptId = requireValue(parsed2.positionals[0], "Attempt id is required for `merge status`");
573
+ if (parsed2.positionals.length > 1) throw new Error(`Unexpected merge status argument: ${parsed2.positionals[1]}`);
574
+ return { kind: "status", project, attemptId };
575
+ }
571
576
  const parsed = parseCommandOperands(
572
577
  args,
573
578
  /* @__PURE__ */ new Set(["--worker"]),
574
579
  /* @__PURE__ */ new Map([
575
580
  ["--create-conflict-worktree", "--create-conflict-worktree"],
576
- ["--launch-conflict-resolution-agent", "--launch-conflict-resolution-agent"]
581
+ ["--launch-conflict-resolution-agent", "--launch-conflict-resolution-agent"],
582
+ ["--no-wait", "--no-wait"]
577
583
  ]),
578
584
  "merge"
579
585
  );
@@ -586,7 +592,14 @@ function parseMergeCommand(options, args) {
586
592
  }
587
593
  const conflictMode = createWorktree ? "worktree" : launchAgent ? "agent" : "none";
588
594
  const worker = parsed.values.get("--worker")?.trim();
589
- return { kind: "merge", project, sourceWorktree, conflictMode, ...worker ? { worker } : {} };
595
+ return {
596
+ kind: "merge",
597
+ project,
598
+ sourceWorktree,
599
+ conflictMode,
600
+ ...worker ? { worker } : {},
601
+ ...parsed.booleans.has("--no-wait") ? { noWait: true } : {}
602
+ };
590
603
  }
591
604
  function assertAuthLoginArgs(args) {
592
605
  const valueFlags = /* @__PURE__ */ new Set(["--device-name", "--worker-label"]);
@@ -1874,17 +1887,22 @@ function renderSessionRunStatus(session) {
1874
1887
  function renderWorktreeMerge(result, input) {
1875
1888
  const lines = [];
1876
1889
  appendMergeDetails(lines, result);
1890
+ const status = responseString(result, "status");
1877
1891
  const conflictWorktree = responseString(result, "conflictWorktree");
1878
1892
  const resolverSessionId = responseString(result, "resolverSessionId");
1893
+ const attemptId = responseString(result, "attemptId");
1879
1894
  const worker = responseString(result, "worker") ?? input.worker;
1880
1895
  if (resolverSessionId) lines.push("", `Resolver session: ${resolverSessionId}`, ` r5dctl session stop ${resolverSessionId}`);
1881
- if (conflictWorktree && worker && ["conflicts", "conflict_worktree_created", "resolving"].includes(responseString(result, "status") ?? "")) {
1896
+ if (conflictWorktree && worker && ["conflicts", "conflict_worktree_created", "resolving"].includes(status ?? "")) {
1882
1897
  lines.push(
1883
1898
  "",
1884
1899
  "Finish after resolving and reviewing the conflict worktree:",
1885
1900
  ` r5dctl -p ${input.project} merge finish --worker ${worker} ${conflictWorktree} --summary "<summary>"`
1886
1901
  );
1887
1902
  }
1903
+ if (attemptId && (status === "provisioning" || status === "resolving" && result.resolverAgentRunning === false)) {
1904
+ lines.push("", "Check progress:", ` r5dctl -p ${input.project} merge status ${attemptId}`);
1905
+ }
1888
1906
  return `${lines.join("\n")}
1889
1907
  `;
1890
1908
  }
@@ -1924,13 +1942,64 @@ async function dispatchSessionRunCommand(client, command) {
1924
1942
  const data = await client.sessions.stop(command.sessionId);
1925
1943
  return { data, human: renderSessionRunStatus(data) };
1926
1944
  }
1927
- async function dispatchMergeCommand(client, command) {
1945
+ const MERGE_POLL_INTERVAL_MS = 2e3;
1946
+ const MERGE_POLL_TIMEOUT_MS = 20 * 6e4;
1947
+ const MERGE_POLL_MAX_CONSECUTIVE_FAILURES = 5;
1948
+ function isMergeAttemptSettled(response) {
1949
+ const status = responseString(response, "status");
1950
+ if (!status) return true;
1951
+ if (status === "resolving") return response.resolverAgentRunning !== false;
1952
+ return status !== "provisioning";
1953
+ }
1954
+ async function pollMergeAttemptUntilSettled(client, project, attemptId, initial, options) {
1955
+ const intervalMs = options.pollIntervalMs ?? MERGE_POLL_INTERVAL_MS;
1956
+ const timeoutMs = options.pollTimeoutMs ?? MERGE_POLL_TIMEOUT_MS;
1957
+ const sleep2 = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1958
+ const onProgress = options.onProgress ?? (() => {
1959
+ });
1960
+ let latest = initial;
1961
+ let worktreeReadyReported = latest.worktreeReady === true;
1962
+ let consecutiveFailures = 0;
1963
+ let waitedMs = 0;
1964
+ while (!isMergeAttemptSettled(latest)) {
1965
+ if (waitedMs >= timeoutMs) {
1966
+ onProgress(`Still waiting after ${Math.round(timeoutMs / 6e4)} minutes; check later with: r5dctl -p ${project} merge status ${attemptId}`);
1967
+ return latest;
1968
+ }
1969
+ await sleep2(intervalMs);
1970
+ waitedMs += intervalMs;
1971
+ try {
1972
+ latest = await client.projects.worktrees.mergeAttempt(project, attemptId);
1973
+ consecutiveFailures = 0;
1974
+ } catch (error) {
1975
+ consecutiveFailures += 1;
1976
+ if (consecutiveFailures >= MERGE_POLL_MAX_CONSECUTIVE_FAILURES) throw error;
1977
+ continue;
1978
+ }
1979
+ if (!worktreeReadyReported && latest.worktreeReady === true) {
1980
+ worktreeReadyReported = true;
1981
+ onProgress("Conflict worktree ready.");
1982
+ }
1983
+ }
1984
+ return latest;
1985
+ }
1986
+ async function dispatchMergeCommand(client, command, pollOptions = {}) {
1928
1987
  if (command.kind === "merge") {
1929
1988
  const data2 = await client.projects.worktrees.merge(command.project, command.sourceWorktree, {
1930
1989
  conflictMode: command.conflictMode,
1931
1990
  ...command.worker ? { worker: command.worker } : {}
1932
1991
  });
1933
- return { data: data2, human: renderWorktreeMerge(data2, command) };
1992
+ const attemptId = responseString(data2, "attemptId");
1993
+ if (command.noWait || !attemptId || isMergeAttemptSettled(data2)) {
1994
+ return { data: data2, human: renderWorktreeMerge(data2, command) };
1995
+ }
1996
+ pollOptions.onProgress?.(`Provisioning the conflict worktree (attempt ${attemptId})...`);
1997
+ const settled = await pollMergeAttemptUntilSettled(client, command.project, attemptId, data2, pollOptions);
1998
+ return { data: settled, human: renderWorktreeMerge(settled, command) };
1999
+ }
2000
+ if (command.kind === "status") {
2001
+ const data2 = await client.projects.worktrees.mergeAttempt(command.project, command.attemptId);
2002
+ return { data: data2, human: renderWorktreeMerge(data2, { project: command.project }) };
1934
2003
  }
1935
2004
  const data = await client.projects.worktrees.finishMerge(command.project, command.conflictWorktree, {
1936
2005
  worker: command.worker,
@@ -3199,7 +3268,18 @@ async function runCommand(argv) {
3199
3268
  });
3200
3269
  }
3201
3270
  if (execution.kind === "merge") {
3202
- const result = await dispatchMergeCommand(client, execution.command);
3271
+ if (!options.json) {
3272
+ if (execution.command.kind === "merge") {
3273
+ process.stderr.write("Merging (publishing the workspace and computing the merge)...\n");
3274
+ } else if (execution.command.kind === "finish") {
3275
+ process.stderr.write(`Finalizing ${execution.command.conflictWorktree} (publishing the resolver workspace)...
3276
+ `);
3277
+ }
3278
+ }
3279
+ const result = await dispatchMergeCommand(client, execution.command, {
3280
+ ...options.json ? {} : { onProgress: (line) => process.stderr.write(`${line}
3281
+ `) }
3282
+ });
3203
3283
  writeDataOutput(options.json, result.data, result.human);
3204
3284
  return 0;
3205
3285
  }
@@ -3274,6 +3354,7 @@ export {
3274
3354
  getR5dctlVersion,
3275
3355
  getTransientDevicePollDelay,
3276
3356
  handleAuthLogin,
3357
+ isMergeAttemptSettled,
3277
3358
  main,
3278
3359
  parseAnswerEnvRequestCommandArgs,
3279
3360
  parseAnswerFlags,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5dctl",
3
- "version": "0.0.72",
3
+ "version": "0.0.73",
4
4
  "type": "module"
5
5
  }
@@ -85,6 +85,11 @@ export type R5dctlMergeCommand = {
85
85
  sourceWorktree: string;
86
86
  worker?: string;
87
87
  conflictMode: R5dctlMergeConflictMode;
88
+ noWait?: boolean;
89
+ } | {
90
+ kind: "status";
91
+ project: string;
92
+ attemptId: string;
88
93
  } | {
89
94
  kind: "finish";
90
95
  project: string;
@@ -229,6 +234,8 @@ export type R5dctlCommandResponse = {
229
234
  conflictedFiles?: string[];
230
235
  failureReason?: string;
231
236
  message?: string;
237
+ worktreeReady?: boolean;
238
+ resolverAgentRunning?: boolean;
232
239
  };
233
240
  export type R5dctlSessionRunClient = {
234
241
  projects: {
@@ -259,6 +266,7 @@ export type R5dctlMergeClient = {
259
266
  worker: string;
260
267
  summary?: string;
261
268
  }) => Promise<R5dctlCommandResponse>;
269
+ mergeAttempt: (projectRef: string, attemptId: string) => Promise<R5dctlCommandResponse>;
262
270
  };
263
271
  };
264
272
  };
@@ -273,7 +281,21 @@ export type R5dctlCommandDispatchResult = {
273
281
  human: string;
274
282
  };
275
283
  export declare function dispatchSessionRunCommand(client: R5dctlSessionRunClient, command: R5dctlSessionRunCommand): Promise<R5dctlCommandDispatchResult>;
276
- export declare function dispatchMergeCommand(client: R5dctlMergeClient, command: R5dctlMergeCommand): Promise<R5dctlCommandDispatchResult>;
284
+ export type R5dctlMergePollOptions = {
285
+ onProgress?: (line: string) => void;
286
+ pollIntervalMs?: number;
287
+ pollTimeoutMs?: number;
288
+ sleep?: (ms: number) => Promise<void>;
289
+ };
290
+ /**
291
+ * Whether a merge attempt response is settled for the CLI: terminal, waiting
292
+ * on the user (a ready manual conflict worktree), or actively resolving with
293
+ * its agent running. Anything else is still provisioning or starting. A
294
+ * "resolving" response without the resolverAgentRunning field comes from a
295
+ * server that starts the agent in-request, so it counts as settled.
296
+ */
297
+ export declare function isMergeAttemptSettled(response: R5dctlCommandResponse): boolean;
298
+ export declare function dispatchMergeCommand(client: R5dctlMergeClient, command: R5dctlMergeCommand, pollOptions?: R5dctlMergePollOptions): Promise<R5dctlCommandDispatchResult>;
277
299
  export declare function parseConversationRenderArgs(args: string[]): ConversationRenderOptions;
278
300
  export declare function renderConversationResponse(read: R5dctlConversationResponse): string;
279
301
  type R5dctlFollowClient = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ricsam/r5dctl",
3
- "version": "0.0.72",
3
+ "version": "0.0.73",
4
4
  "type": "module",
5
5
  "main": "./dist/cjs/cli.cjs",
6
6
  "module": "./dist/mjs/cli.mjs",
@@ -26,7 +26,7 @@
26
26
  "r5dctl": "dist/cjs/main.cjs"
27
27
  },
28
28
  "dependencies": {
29
- "@ricsam/r5d-api": "^0.0.72",
29
+ "@ricsam/r5d-api": "^0.0.73",
30
30
  "dotenv": "^17",
31
31
  "qrcode": "^1.5.4",
32
32
  "ws": "^8.18.3"