@narumitw/pi-sync 0.57.2 → 0.57.5

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 (89) hide show
  1. package/README.md +5 -1
  2. package/dist/chunks/{agent-plan-K3CRYVXV.ts → agent-plan-3L7CRFDM.ts} +28 -11
  3. package/dist/chunks/agent-plan-3L7CRFDM.ts.map +7 -0
  4. package/dist/chunks/agent-refusal-retry-AH7TXOQT.ts +69 -0
  5. package/dist/chunks/agent-refusal-retry-AH7TXOQT.ts.map +7 -0
  6. package/dist/chunks/{agent-runner-E2JU2VZ6.ts → agent-runner-WMSCKSCB.ts} +7 -5
  7. package/dist/chunks/agent-runner-WMSCKSCB.ts.map +7 -0
  8. package/dist/chunks/{agent-store-A3F6IYWZ.ts → agent-store-55CZOCPX.ts} +25 -3
  9. package/dist/chunks/agent-store-55CZOCPX.ts.map +7 -0
  10. package/dist/chunks/{chunk-QGK445K7.ts → chunk-3ST7RSYN.ts} +1 -1
  11. package/dist/chunks/chunk-3ST7RSYN.ts.map +7 -0
  12. package/dist/chunks/{chunk-OWBAZA6J.ts → chunk-DT6TOCIG.ts} +2 -2
  13. package/dist/chunks/{chunk-JJTARUSO.ts → chunk-JCH3VXIO.ts} +3 -2
  14. package/dist/chunks/{chunk-JJTARUSO.ts.map → chunk-JCH3VXIO.ts.map} +2 -2
  15. package/dist/chunks/{chunk-OEWQVWLC.ts → chunk-ORGBI36T.ts} +4 -4
  16. package/dist/chunks/{chunk-EUMINX7L.ts → chunk-P7DD5PWZ.ts} +2 -2
  17. package/dist/chunks/{chunk-VJN7KU4J.ts → chunk-PG7NMFP2.ts} +83 -18
  18. package/dist/chunks/chunk-PG7NMFP2.ts.map +7 -0
  19. package/dist/chunks/{chunk-T3NC77TU.ts → chunk-R75IHAOJ.ts} +45 -18
  20. package/dist/chunks/chunk-R75IHAOJ.ts.map +7 -0
  21. package/dist/chunks/{chunk-4TQWFLOQ.ts → chunk-U3XRI2HF.ts} +28 -197
  22. package/dist/chunks/chunk-U3XRI2HF.ts.map +7 -0
  23. package/dist/chunks/chunk-WD6FJHCK.ts +140 -0
  24. package/dist/chunks/chunk-WD6FJHCK.ts.map +7 -0
  25. package/dist/chunks/{conflict-review-ZCGWFKEK.ts → conflict-review-T7GSOKNG.ts} +9 -8
  26. package/dist/chunks/{conflict-review-ZCGWFKEK.ts.map → conflict-review-T7GSOKNG.ts.map} +1 -1
  27. package/dist/chunks/{continuous-record-LFRVXQBD.ts → continuous-record-SQJ47CXN.ts} +3 -3
  28. package/dist/chunks/{continuous-sync-LAIMSW7D.ts → continuous-sync-U4HIKPQK.ts} +9 -9
  29. package/dist/chunks/{continuous-sync-LAIMSW7D.ts.map → continuous-sync-U4HIKPQK.ts.map} +2 -2
  30. package/dist/chunks/{git-backend-KYDZ673X.ts → git-backend-NYKSG5X6.ts} +4 -4
  31. package/dist/chunks/{manager-result-dispatcher-AGBBVDDS.ts → manager-result-dispatcher-HOCM2GDA.ts} +4 -4
  32. package/dist/chunks/{manager-ui-PSA2L6CE.ts → manager-ui-FXXO6TW4.ts} +54 -17
  33. package/dist/chunks/manager-ui-FXXO6TW4.ts.map +7 -0
  34. package/dist/chunks/{remote-selection-ui-5725JDNC.ts → remote-selection-ui-YFPGYGNE.ts} +3 -3
  35. package/dist/chunks/{s3-backend-Y7WSCLJI.ts → s3-backend-F7PR5FB3.ts} +4 -4
  36. package/dist/chunks/{setup-wizard-SOYOJQKP.ts → setup-wizard-OVHZYXNW.ts} +2 -2
  37. package/dist/chunks/{sync-inspection-XFJOAMJD.ts → sync-inspection-5MY2HI4P.ts} +3 -3
  38. package/dist/chunks/{sync-operations-W5HFFMTP.ts → sync-operations-GNPS6BLY.ts} +29 -17
  39. package/dist/chunks/sync-operations-GNPS6BLY.ts.map +7 -0
  40. package/dist/chunks/{webdav-backend-6K26F6LT.ts → webdav-backend-TGFUH3SF.ts} +4 -4
  41. package/dist/index.ts +28 -23
  42. package/dist/index.ts.map +2 -2
  43. package/docs/agent-continuous-hardening.md +79 -0
  44. package/docs/agent-mode-verification.md +2 -0
  45. package/docs/agent-quality-verification.md +41 -0
  46. package/docs/continuous-sync-verification.md +2 -0
  47. package/package.json +4 -4
  48. package/skills/configure-pi-sync/references/configuration.md +21 -5
  49. package/src/commands/command-execution.ts +7 -2
  50. package/src/commands/command-handler.ts +3 -1
  51. package/src/commands/command-types.ts +3 -1
  52. package/src/sync/agent-plan.ts +26 -9
  53. package/src/sync/agent-refusal-retry.ts +54 -0
  54. package/src/sync/agent-request.ts +3 -1
  55. package/src/sync/agent-runner.ts +8 -6
  56. package/src/sync/agent-store.ts +32 -1
  57. package/src/sync/agent-sync.ts +63 -13
  58. package/src/sync/continuous-lease.ts +2 -7
  59. package/src/sync/continuous-record.ts +95 -13
  60. package/src/sync/continuous-storage.ts +12 -3
  61. package/src/sync/continuous-sync.ts +2 -1
  62. package/src/sync/merged-sync.ts +37 -9
  63. package/src/sync/sync-mutations.ts +11 -0
  64. package/src/ui/cancellable-operation.ts +2 -0
  65. package/src/ui/manager-state.ts +1 -0
  66. package/src/ui/manager-ui.ts +59 -14
  67. package/src/ui/secret-input.ts +29 -211
  68. package/dist/chunks/agent-plan-K3CRYVXV.ts.map +0 -7
  69. package/dist/chunks/agent-runner-E2JU2VZ6.ts.map +0 -7
  70. package/dist/chunks/agent-store-A3F6IYWZ.ts.map +0 -7
  71. package/dist/chunks/chunk-4TQWFLOQ.ts.map +0 -7
  72. package/dist/chunks/chunk-QGK445K7.ts.map +0 -7
  73. package/dist/chunks/chunk-QOC47PBC.ts +0 -97
  74. package/dist/chunks/chunk-QOC47PBC.ts.map +0 -7
  75. package/dist/chunks/chunk-T3NC77TU.ts.map +0 -7
  76. package/dist/chunks/chunk-VJN7KU4J.ts.map +0 -7
  77. package/dist/chunks/manager-ui-PSA2L6CE.ts.map +0 -7
  78. package/dist/chunks/sync-operations-W5HFFMTP.ts.map +0 -7
  79. /package/dist/chunks/{chunk-OWBAZA6J.ts.map → chunk-DT6TOCIG.ts.map} +0 -0
  80. /package/dist/chunks/{chunk-OEWQVWLC.ts.map → chunk-ORGBI36T.ts.map} +0 -0
  81. /package/dist/chunks/{chunk-EUMINX7L.ts.map → chunk-P7DD5PWZ.ts.map} +0 -0
  82. /package/dist/chunks/{continuous-record-LFRVXQBD.ts.map → continuous-record-SQJ47CXN.ts.map} +0 -0
  83. /package/dist/chunks/{git-backend-KYDZ673X.ts.map → git-backend-NYKSG5X6.ts.map} +0 -0
  84. /package/dist/chunks/{manager-result-dispatcher-AGBBVDDS.ts.map → manager-result-dispatcher-HOCM2GDA.ts.map} +0 -0
  85. /package/dist/chunks/{remote-selection-ui-5725JDNC.ts.map → remote-selection-ui-YFPGYGNE.ts.map} +0 -0
  86. /package/dist/chunks/{s3-backend-Y7WSCLJI.ts.map → s3-backend-F7PR5FB3.ts.map} +0 -0
  87. /package/dist/chunks/{setup-wizard-SOYOJQKP.ts.map → setup-wizard-OVHZYXNW.ts.map} +0 -0
  88. /package/dist/chunks/{sync-inspection-XFJOAMJD.ts.map → sync-inspection-5MY2HI4P.ts.map} +0 -0
  89. /package/dist/chunks/{webdav-backend-6K26F6LT.ts.map → webdav-backend-TGFUH3SF.ts.map} +0 -0
@@ -377,9 +377,9 @@ A verified accepted ancestor is required.
377
377
  Enabling Agent mode cannot recover historical ancestor bytes that were never retained.
378
378
  If the original accepted bytes are still available on both sides, a conflict-free sync can seed the text ancestors for future runs; otherwise use the existing reviewed recovery flow rather than inventing a base from current edits.
379
379
 
380
- Bounds are 8 KiB per input file, 16 conflict paths/units, 24 KiB of serialized model evidence, 16 KiB of candidate output, 8,192 maximum output tokens (or the selected model's smaller positive output limit), and one provider request per candidate.
380
+ Bounds are 8 KiB per input file, 16 conflict paths/units, 24 KiB of serialized model evidence, 16 KiB of candidate output, 8,192 maximum output tokens (or the selected model's smaller positive output limit), and one provider request per candidate attempt. A separately confirmed refusal retry can request the same candidate evidence again.
381
381
  An unknown model limit of zero retains the resolver cap; invalid limits pause before disclosure.
382
- Each resolver has a 20-second deadline; the existing overall startup deadline is 30 seconds.
382
+ Each resolver has a 20-second deadline that also cancels its validation and reservation prerequisites where cancellation is supported; the existing overall startup deadline is 30 seconds. Cleanup drains owned work and can take additional time. An atomic reservation or file replacement cannot be undone after publication.
383
383
  At most two candidate requests are made during one triggered flow when remote commit competition changes the plan.
384
384
  Provider retries are disabled.
385
385
  Invalid output, detected stale evidence, missing model/auth, or recovery uncertainty pauses the flow rather than forcing acceptance.
@@ -404,6 +404,16 @@ Private claim and content-keyed candidate records reside under `<state-root>/age
404
404
  Candidates can be reused only for matching evidence, policies and model identity, and are validated again.
405
405
  They can contain merged text; retain them as private data.
406
406
  Different candidates are retained without automatic retention pruning.
407
+ Ordinary **Sync now** and `/sync sync` reuse a valid cached refusal without another provider request.
408
+ The manager's **Retry refused model result…** action separately reviews the setup, destination, conflict paths, selected model, disclosure and cost before one fresh request for that exact refusal.
409
+ It requires foreground TUI/RPC, Agent mode, an accepted baseline and no pending journal; use ordinary Sync now or reviewed recovery first when those prerequisites are absent.
410
+ Changed evidence, settings, model, session or cached bytes invalidate the review without disclosure.
411
+ A repeated refusal, invalid response or failed pre-publication replacement retains the original result.
412
+ A complete validated result replaces the active candidate only after the original refusal is archived privately in the same directory.
413
+ These archives use the existing candidate format and remain retained; no settings or accounting format migration is added.
414
+ Cancellation cannot undo an already committed replacement or provider request.
415
+ The action does not reset automatic accounting and never runs from background polling; no new textual command or flag is provided.
416
+ When partial sync is off, known unsupported groups stop before inference rather than paying for a result that cannot complete the transfer.
407
417
  Duplicate provider billing remains possible at crash boundaries.
408
418
  Corrupt or unsafe records block reuse rather than being overwritten blindly.
409
419
 
@@ -472,7 +482,7 @@ Cycles do not overlap.
472
482
  Each cycle has a 30-second deadline plus underlying cleanup.
473
483
  Changes during a cycle coalesce into one later fresh inspection rather than a queue of stale commands.
474
484
 
475
- Known transient network/server errors and rate limits retry from five seconds with exponential backoff, bounded jitter and a five-minute cap.
485
+ Known transient network/server errors, rate limits and proven pre-publication local/baseline/remote freshness races retry from five seconds with exponential backoff, bounded jitter and a five-minute cap.
476
486
  Valid server Retry-After delays are capped at five minutes.
477
487
  Backoff survives restart and is reset by successful work or relevant configuration changes, not every file event.
478
488
 
@@ -505,8 +515,9 @@ Automatic requests are limited to:
505
515
  A refusal or invalid output blocks unchanged evidence rather than requesting another completion each poll.
506
516
  When the rolling window is full, Status shows the next eligible attempt.
507
517
  Checks, conflict-free transfers and journal recovery can continue without another model request.
508
- When the candidate's two attempts are spent, use changed evidence or an explicit retry.
509
- The explicit **Sync now** / `/sync sync` path retains its existing per-flow limits; it does not erase automatic accounting.
518
+ When the candidate's two automatic attempts are spent, waiting does not restore them.
519
+ The explicit **Sync now** / `/sync sync` path retains its existing per-flow limits; it does not erase automatic accounting and continues to reuse cached refusals.
520
+ Use changed evidence, the separately confirmed **Retry refused model result…** action for a current cached refusal, or an existing reviewed recovery route as appropriate.
510
521
  Opening Status never reserves requests or clears limits.
511
522
 
512
523
  These limits are not a currency spending cap or an exactly-once billing guarantee.
@@ -523,6 +534,11 @@ Keep the directory as private evidence; do not delete it to repair a transfer.
523
534
 
524
535
  The existing status key shows waiting, checking/syncing, scheduled retry, another local owner, or a stable blocked reason.
525
536
  **Check remote status** and the manager show advisory ownership, the last successful background check/transfer and safe next actions.
537
+ Status also shows normalized rolling-window usage, the next window allowance, exhausted-candidate counts, serialized accounting bytes/capacity and on-disk bytes.
538
+ Window allowance is not dispatch permission: candidate, capacity, ownership and safety checks still apply.
539
+ Status normalization affects only the detached read; it does not expire durable calls, reserve requests, create private directories or reset limits.
540
+ The scheduler owner's model is reported as unavailable rather than inferred from the observing session.
541
+ A full or unverifiable record is not unused allowance; retain it and use reviewed repair instead of deleting accounting.
526
542
  Notifications are deduplicated by error/evidence/configuration.
527
543
  Status may itself need network access for its existing remote check, but does not call the model.
528
544
 
@@ -74,6 +74,7 @@ export async function executeCommand(
74
74
  onCommit?: () => void,
75
75
  setup?: string,
76
76
  onDialog?: (active: boolean) => void,
77
+ retryRefusal = false,
77
78
  ): Promise<RunRouteResult> {
78
79
  ctx = trackOperationDialogs(ctx, onDialog);
79
80
  try {
@@ -85,6 +86,10 @@ export async function executeCommand(
85
86
  if (signal) options.signal = signal;
86
87
  if (onCommit) options.onCommit = onCommit;
87
88
  validateCommandOptions(subcommand, options);
89
+ if (retryRefusal) {
90
+ if (subcommand !== "sync") throw new Error("A refused-model retry is available only for merged sync.");
91
+ options.agentRetryRefusal = true;
92
+ }
88
93
 
89
94
  switch (subcommand) {
90
95
  case "help":
@@ -157,8 +162,8 @@ export async function executeCommand(
157
162
  case "sync": {
158
163
  const operations = await loaders.operations();
159
164
  throwIfAborted(options.signal);
160
- await withAgentSyncLock(ctx, options, (current) => operations.syncBoth(ctx, current));
161
- return { kind: "completed" };
165
+ const outcome = await withAgentSyncLock(ctx, options, (current) => operations.syncBoth(ctx, current));
166
+ return { kind: "completed", ...(retryRefusal && outcome ? { outcome } : {}) };
162
167
  }
163
168
  case "history": {
164
169
  const operations = await loaders.operations();
@@ -32,9 +32,10 @@ export async function handleCommand(
32
32
  onCommit?: () => void,
33
33
  target?: string,
34
34
  onDialog?: (active: boolean) => void,
35
+ retryRefusal = false,
35
36
  ) => {
36
37
  const [command, ...args] = splitArgs(route);
37
- if (command === "sync") {
38
+ if (command === "sync" && !retryRefusal) {
38
39
  try {
39
40
  const options = parseOptions(args);
40
41
  validateCommandOptions("sync", options);
@@ -51,6 +52,7 @@ export async function handleCommand(
51
52
  observationCommitCallback(attention, sessionSignal, onCommit),
52
53
  target,
53
54
  onDialog,
55
+ retryRefusal,
54
56
  );
55
57
  };
56
58
  if (!rawArgs.trim()) {
@@ -17,7 +17,9 @@ export interface CommandOptions {
17
17
  /** Internal validated resolver result; never parsed from command arguments. */
18
18
  agentAcceptance?: AgentAcceptance;
19
19
  /** Internal cycle authorization, ownership and durable request reservation; never command arguments. */
20
- background?: { validate(config?: AnySyncConfig): void; reserve(key: string): Promise<void> };
20
+ background?: { validate(config?: AnySyncConfig): void; reserve(key: string, signal?: AbortSignal): Promise<void> };
21
+ /** Manager-only, separately confirmed retry; never parsed from command arguments. */
22
+ agentRetryRefusal?: boolean;
21
23
  args: string[];
22
24
  }
23
25
 
@@ -28,7 +28,11 @@ export async function resolveAgentPlan(
28
28
  snapshotOptions: SnapshotOptions,
29
29
  validate: () => Promise<void>,
30
30
  ): Promise<FileMergePlan> {
31
- if (plan.kind !== "planned" || !plan.conflicts.length) return plan;
31
+ if (plan.kind !== "planned" || !plan.conflicts.length) {
32
+ if (options.agentRetryRefusal)
33
+ throw new ContinuousError("unsafe", "No eligible cached refusal for the current evidence. Use Sync now.");
34
+ return plan;
35
+ }
32
36
  if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc"))
33
37
  throw new Error("Agent mode paused: TUI/RPC is required.");
34
38
  const ancestors = (await readMergeAncestors(config, state)) ?? [];
@@ -39,14 +43,22 @@ export async function resolveAgentPlan(
39
43
  } catch {
40
44
  throw new ContinuousError("unsafe", "Agent mode paused: unsafe or unsupported conflict evidence.");
41
45
  }
46
+ if (!config.partialSync && evidence.unsupported.length)
47
+ throw new ContinuousError("unsafe", "Agent mode paused: unsupported conflicts prevent an all-or-nothing transfer.");
42
48
  if (!evidence.files.length) {
49
+ if (options.agentRetryRefusal)
50
+ throw new ContinuousError("unsafe", "No eligible cached refusal for the current evidence. Use Sync now.");
43
51
  if (config.partialSync) return plan;
44
52
  throw new ContinuousError(
45
53
  "unsafe",
46
54
  "Agent mode paused: unsupported conflict or missing verified ancestor; evidence is unchanged.",
47
55
  );
48
56
  }
49
- if (!evidence.units.length) return applyAgentCandidate(plan, evidence, { version: 1, results: [] });
57
+ if (!evidence.units.length) {
58
+ if (options.agentRetryRefusal)
59
+ throw new ContinuousError("unsafe", "No eligible cached refusal for the current evidence. Use Sync now.");
60
+ return applyAgentCandidate(plan, evidence, { version: 1, results: [] });
61
+ }
50
62
  if (!ctx.model) throw new ContinuousError("model", "Agent mode paused: select an available model first.");
51
63
  const model = digest(ctx.model);
52
64
  const key = digest([
@@ -66,15 +78,20 @@ export async function resolveAgentPlan(
66
78
  throw new ContinuousError("unsafe", "Agent mode paused: candidate refused or unsupported conflict remains.");
67
79
  }
68
80
  }
69
- const validateEvidence = async () => {
70
- await validate();
81
+ const validateEvidence = async (signal = options.signal) => {
82
+ const check = async () => {
83
+ signal?.throwIfAborted();
84
+ await validate();
85
+ signal?.throwIfAborted();
86
+ };
87
+ await check();
71
88
  if (digest(ctx.model) !== model) throw new Error("Agent mode paused: selected model changed.");
72
- const current = await createSnapshot(config.snapshotIdentity, snapshotOptions);
73
- await validate();
89
+ const current = await createSnapshot(config.snapshotIdentity, { ...snapshotOptions, signal });
90
+ await check();
74
91
  const currentState = await readStateForConfig(config);
75
- await validate();
76
- const currentHead = await backend.readHead(options.signal);
77
- await validate();
92
+ await check();
93
+ const currentHead = await backend.readHead(signal);
94
+ await check();
78
95
  if (
79
96
  !sameHashes(fileHashMap(current), fileHashMap(localRaw)) ||
80
97
  syncStateFingerprint(currentState) !== syncStateFingerprint(state) ||
@@ -0,0 +1,54 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { loadConfig } from "../settings/config.js";
3
+ import { confirmMergeReview } from "../ui/merge-review.js";
4
+ import { safeTerminalText } from "../ui/terminal-text.js";
5
+ import type { AgentEvidence } from "./agent-candidate.js";
6
+ import { captureMutationOwner } from "./sync-local.js";
7
+
8
+ /** Review uses fresh request validation; no private text or provider error enters the summary. */
9
+ export async function confirmAgentRefusalRetry(
10
+ ctx: ExtensionContext,
11
+ setup: string | undefined,
12
+ evidence: AgentEvidence,
13
+ validate: () => Promise<void>,
14
+ signal?: AbortSignal,
15
+ ) {
16
+ const owner = captureMutationOwner(ctx, signal);
17
+ owner();
18
+ const config = await loadConfig(setup);
19
+ owner();
20
+ await validate();
21
+ owner();
22
+ const summary = [
23
+ `Setup: ${safeTerminalText(config.setupName)}`,
24
+ `Storage connection: ${safeTerminalText(config.connectionName)}`,
25
+ `Storage path: ${safeTerminalText(config.storagePath)}`,
26
+ ...(config.backend.type === "s3" ? [`Bucket: ${safeTerminalText(config.backend.destination.bucket)}`] : []),
27
+ ...(config.backend.type === "git" ? [`Branch: ${safeTerminalText(config.backend.destination.branch)}`] : []),
28
+ `Selected model: ${safeTerminalText(ctx.model?.provider ?? "unknown")}/${safeTerminalText(ctx.model?.id ?? "unknown")}`,
29
+ `Conflict paths: ${[...new Set(evidence.units.map((unit) => unit.path))].map(safeTerminalText).join(", ")}`,
30
+ "Send this conflict evidence again despite the cached refusal? This permits one fresh provider request for the reviewed candidate, may incur costs, and can replace selected local and remote content if the result passes validation. Semantic correctness is not guaranteed.",
31
+ "Ordinary Sync now continues to reuse cached results. Automatic request accounting is not cleared or reset. The previous refusal is retained privately. No resources reload automatically. Cancellation cannot recall submitted content, provider charges, or committed writes.",
32
+ ].join("\n\n");
33
+ const confirmed = await confirmMergeReview(
34
+ ctx,
35
+ "Retry refused model result?",
36
+ summary,
37
+ signal,
38
+ () => {
39
+ try {
40
+ owner();
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ },
46
+ "Send one fresh request",
47
+ summary.split("\n"),
48
+ );
49
+ owner();
50
+ if (!confirmed) return false;
51
+ await validate();
52
+ owner();
53
+ return true;
54
+ }
@@ -3,13 +3,15 @@ import type { AgentCandidate, AgentEvidence } from "./agent-candidate.js";
3
3
  export interface AgentAcceptance {
4
4
  key: string;
5
5
  candidate: AgentCandidate;
6
+ /** Retry-only cache publication after the full merge/application preflight passes. */
7
+ commitCandidate?: () => Promise<void>;
6
8
  }
7
9
  /** Internal control flow: the operation lock must unwind before any provider call. */
8
10
  export class AgentResolutionRequired extends Error {
9
11
  constructor(
10
12
  readonly key: string,
11
13
  readonly evidence: AgentEvidence,
12
- readonly validate: () => Promise<void>,
14
+ readonly validate: (signal?: AbortSignal) => Promise<void>,
13
15
  ) {
14
16
  super("Agent resolution requires an unlocked inference phase.");
15
17
  this.name = "AgentResolutionRequired";
@@ -34,9 +34,9 @@ export function resolverResources(): ResourceLoader {
34
34
  export async function runAgentResolver(
35
35
  ctx: ExtensionContext,
36
36
  evidence: AgentEvidence,
37
- validate: () => Promise<void>,
37
+ validate: (signal: AbortSignal) => Promise<void>,
38
38
  signal?: AbortSignal,
39
- beforeRequest?: () => Promise<void>,
39
+ beforeRequest?: (signal: AbortSignal) => Promise<void>,
40
40
  ): Promise<string> {
41
41
  const model = ctx.model;
42
42
  if (!model) throw new ContinuousError("model", "Agent mode paused: select an available model first.");
@@ -105,12 +105,13 @@ export async function runAgentResolver(
105
105
  child.agent.streamFunction = async (_model, context, options) => {
106
106
  ownedSignal.throwIfAborted();
107
107
  if (++calls > 1) throw new Error("Agent mode paused: one provider request per candidate is permitted.");
108
- await validate();
108
+ await validate(ownedSignal);
109
109
  ownedSignal.throwIfAborted();
110
110
  if (beforeRequest) {
111
111
  try {
112
- await beforeRequest();
113
- await validate();
112
+ await beforeRequest(ownedSignal);
113
+ ownedSignal.throwIfAborted();
114
+ await validate(ownedSignal);
114
115
  ownedSignal.throwIfAborted();
115
116
  } catch (error) {
116
117
  transportFailure = error;
@@ -137,7 +138,8 @@ export async function runAgentResolver(
137
138
  ownedSignal.throwIfAborted();
138
139
  await child.prompt(JSON.stringify({ units: evidence.units }));
139
140
  ownedSignal.throwIfAborted();
140
- await validate();
141
+ await validate(ownedSignal);
142
+ ownedSignal.throwIfAborted();
141
143
  const last = child.messages.at(-1);
142
144
  if (
143
145
  last?.role !== "assistant" ||
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import lockfile from "proper-lockfile";
@@ -7,6 +7,7 @@ import { withLock } from "../state/lock.js";
7
7
  import { LOCK_GUARD_STALE_MS, LOCK_GUARD_UPDATE_MS } from "../state/lock-policy.js";
8
8
  import { LOCKFILE_FS_ADAPTER } from "../state/lockfile-fs.js";
9
9
  import { stateDir } from "../state/state-directory.js";
10
+ import type { AgentCandidate } from "./agent-candidate.js";
10
11
  import { AGENT_OUTPUT_BYTES } from "./agent-policy.js";
11
12
  import { ContinuousError } from "./continuous-errors.js";
12
13
 
@@ -149,3 +150,33 @@ export async function saveAgentCandidate(key: string, candidate: unknown) {
149
150
  }
150
151
  await writeJson(target, candidate, { maxBytes: AGENT_OUTPUT_BYTES * 2 });
151
152
  }
153
+
154
+ /** Caller holds the resolver claim and operation lock and has validated both candidates. */
155
+ export async function replaceRefusedAgentCandidate(
156
+ key: string,
157
+ previous: AgentCandidate,
158
+ candidate: AgentCandidate,
159
+ validate: () => Promise<void>,
160
+ ) {
161
+ const original = JSON.stringify(previous);
162
+ if (!previous.results.some((row) => "refuse" in row) || candidate.results.some((row) => "refuse" in row))
163
+ throw new Error("Only a validated refusal can be replaced by a complete retry result.");
164
+ const target = candidatePath(key);
165
+ const assertPrevious = async () => {
166
+ if (JSON.stringify(await readPrivate(target)) !== original)
167
+ throw new Error("Cached refusal changed during review; previous evidence retained.");
168
+ await validate();
169
+ };
170
+ await assertPrevious();
171
+ const digest = createHash("sha256").update(original).digest("hex");
172
+ const archive = path.join(paths().root, `${key}.refusal-${digest}.json`);
173
+ const retained = await readPrivate(archive);
174
+ await validate();
175
+ if (retained !== undefined && JSON.stringify(retained) !== original)
176
+ throw new Error("Retained refusal changed; evidence retained.");
177
+ if (retained === undefined) await writeJson(archive, previous, { maxBytes: AGENT_OUTPUT_BYTES * 2 });
178
+ await assertPrevious();
179
+ // Archive first: even a durability error after replacement retains the original refusal.
180
+ // Once rename commits, later cancellation cannot undo the valid new candidate.
181
+ await writeJson(target, candidate, { maxBytes: AGENT_OUTPUT_BYTES * 2 });
182
+ }
@@ -11,16 +11,21 @@ export async function withAgentSyncLock<T>(
11
11
  ctx: ExtensionContext,
12
12
  options: CommandOptions,
13
13
  run: (options: CommandOptions) => Promise<T>,
14
- ): Promise<T> {
14
+ ): Promise<T | "cancelled"> {
15
15
  let request: AgentResolutionRequired;
16
16
  const background = options.background;
17
+ const retry = Boolean(options.agentRetryRefusal);
18
+ if (retry && (options.auto || background || !ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc")))
19
+ throw new Error("A refused-model retry requires a foreground TUI or RPC manager review.");
17
20
  try {
18
21
  return await withLock("sync", () => run(options));
19
22
  } catch (error) {
20
23
  if (!(error instanceof AgentResolutionRequired)) throw error;
21
24
  request = error;
22
25
  }
23
- const { withAgentClaim, readAgentCandidate, saveAgentCandidate } = await import("./agent-store.js");
26
+ const { withAgentClaim, readAgentCandidate, saveAgentCandidate, replaceRefusedAgentCandidate } = await import(
27
+ "./agent-store.js"
28
+ );
24
29
  const { parseAgentCandidate } = await import("./agent-candidate.js");
25
30
  const { runAgentResolver } = await import("./agent-runner.js");
26
31
  return withAgentClaim(async (assertOwner) => {
@@ -36,27 +41,48 @@ export async function withAgentSyncLock<T>(
36
41
  try {
37
42
  options.signal?.throwIfAborted();
38
43
  for (let attempt = 0; attempt < AGENT_MAX_ATTEMPTS; attempt++) {
39
- const validate = () =>
44
+ let expectedRefusal: ReturnType<typeof parseAgentCandidate> | undefined;
45
+ const validate = (signal = options.signal) =>
40
46
  withLock("agent-validate", async () => {
41
- options.signal?.throwIfAborted();
47
+ signal?.throwIfAborted();
42
48
  await assertOwner();
43
- await request.validate();
44
- options.signal?.throwIfAborted();
49
+ signal?.throwIfAborted();
50
+ if (
51
+ expectedRefusal &&
52
+ JSON.stringify(await readAgentCandidate(request.key)) !== JSON.stringify(expectedRefusal)
53
+ )
54
+ throw new Error("Cached refusal changed during review; reopen the manager.");
55
+ signal?.throwIfAborted();
56
+ await request.validate(signal);
57
+ signal?.throwIfAborted();
45
58
  });
46
59
  await validate();
47
60
  setSyncStatus(ctx, "sync resolving");
48
61
  const cached = await withLock("agent-cache-read", async () => {
49
62
  await assertOwner();
63
+ options.signal?.throwIfAborted();
50
64
  return readAgentCandidate(request.key);
51
65
  });
66
+ options.signal?.throwIfAborted();
67
+ if (retry) {
68
+ if (cached === undefined)
69
+ throw new Error("No eligible cached refusal for the current evidence. Use Sync now first.");
70
+ expectedRefusal = parseAgentCandidate(JSON.stringify(cached), request.evidence);
71
+ if (!expectedRefusal.results.some((row) => "refuse" in row))
72
+ throw new Error("The current cached result is not a refusal. Use Sync now.");
73
+ const { confirmAgentRefusalRetry } = await import("./agent-refusal-retry.js");
74
+ options.signal?.throwIfAborted();
75
+ if (!(await confirmAgentRefusalRetry(ctx, options.setup, request.evidence, validate, options.signal)))
76
+ return "cancelled" as const;
77
+ }
52
78
  const raw =
53
- cached === undefined
79
+ cached === undefined || retry
54
80
  ? await runAgentResolver(
55
81
  ctx,
56
82
  request.evidence,
57
83
  validate,
58
84
  options.signal,
59
- background ? () => background.reserve(request.key) : undefined,
85
+ background ? (signal) => background.reserve(request.key, signal) : undefined,
60
86
  )
61
87
  : JSON.stringify(cached);
62
88
  let candidate: ReturnType<typeof parseAgentCandidate>;
@@ -65,12 +91,35 @@ export async function withAgentSyncLock<T>(
65
91
  } catch {
66
92
  throw new ContinuousError("model", "Agent mode paused: invalid resolver output; evidence retained.");
67
93
  }
94
+ if (retry && candidate.results.some((row) => "refuse" in row))
95
+ throw new ContinuousError("model", "The model refused again. The previous refusal was retained.");
68
96
  await validate();
69
- await withLock("agent-cache-save", async () => {
70
- await assertOwner();
71
- await saveAgentCandidate(request.key, candidate);
72
- });
73
- const acceptance: AgentAcceptance = { key: request.key, candidate };
97
+ const previous = expectedRefusal;
98
+ if (!previous)
99
+ await withLock("agent-cache-save", async () => {
100
+ await assertOwner();
101
+ options.signal?.throwIfAborted();
102
+ await saveAgentCandidate(request.key, candidate);
103
+ });
104
+ let retryCacheCommitted = false;
105
+ const acceptance: AgentAcceptance = {
106
+ key: request.key,
107
+ candidate,
108
+ ...(previous
109
+ ? {
110
+ commitCandidate: async () => {
111
+ if (retryCacheCommitted) return;
112
+ await replaceRefusedAgentCandidate(request.key, previous, candidate, async () => {
113
+ options.signal?.throwIfAborted();
114
+ await assertOwner();
115
+ await request.validate(options.signal);
116
+ options.signal?.throwIfAborted();
117
+ });
118
+ retryCacheCommitted = true;
119
+ },
120
+ }
121
+ : {}),
122
+ };
74
123
  try {
75
124
  return await withLock("sync", async () => {
76
125
  await assertOwner();
@@ -79,6 +128,7 @@ export async function withAgentSyncLock<T>(
79
128
  });
80
129
  } catch (error) {
81
130
  if (!(error instanceof AgentResolutionRequired)) throw error;
131
+ if (retry) throw new ContinuousError("stale", "Reviewed refusal evidence changed; reopen the manager.");
82
132
  request = error;
83
133
  }
84
134
  }
@@ -49,13 +49,8 @@ export async function continuousLeaseOwner(binding: string): Promise<string> {
49
49
  if (!validOwner(owner)) throw new Error("Invalid continuous sync owner");
50
50
  try {
51
51
  const guard = await fs.lstat(path.join(continuousRoot(), "scheduler.lock"));
52
- if (
53
- guard.isDirectory() &&
54
- !guard.isSymbolicLink() &&
55
- Date.now() - guard.mtimeMs <= STALE_MS &&
56
- owner.binding === binding
57
- )
58
- return `Local scheduler lease: observed for pid ${owner.pid}; this can be another session`;
52
+ if (guard.isDirectory() && !guard.isSymbolicLink() && Date.now() - guard.mtimeMs <= STALE_MS)
53
+ return `Local scheduler lease: observed for pid ${owner.pid}; ${owner.binding === binding ? "this can be another session" : "another setup; this setup is standby"}`;
59
54
  } catch (error) {
60
55
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
61
56
  }