@narumitw/pi-sync 0.57.2 → 0.57.4
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 +5 -1
- package/dist/chunks/{agent-plan-K3CRYVXV.ts → agent-plan-3L7CRFDM.ts} +28 -11
- package/dist/chunks/agent-plan-3L7CRFDM.ts.map +7 -0
- package/dist/chunks/agent-refusal-retry-AH7TXOQT.ts +69 -0
- package/dist/chunks/agent-refusal-retry-AH7TXOQT.ts.map +7 -0
- package/dist/chunks/{agent-runner-E2JU2VZ6.ts → agent-runner-WMSCKSCB.ts} +7 -5
- package/dist/chunks/agent-runner-WMSCKSCB.ts.map +7 -0
- package/dist/chunks/{agent-store-A3F6IYWZ.ts → agent-store-55CZOCPX.ts} +25 -3
- package/dist/chunks/agent-store-55CZOCPX.ts.map +7 -0
- package/dist/chunks/{chunk-QGK445K7.ts → chunk-3ST7RSYN.ts} +1 -1
- package/dist/chunks/chunk-3ST7RSYN.ts.map +7 -0
- package/dist/chunks/{chunk-OWBAZA6J.ts → chunk-DT6TOCIG.ts} +2 -2
- package/dist/chunks/{chunk-JJTARUSO.ts → chunk-JCH3VXIO.ts} +3 -2
- package/dist/chunks/{chunk-JJTARUSO.ts.map → chunk-JCH3VXIO.ts.map} +2 -2
- package/dist/chunks/{chunk-OEWQVWLC.ts → chunk-ORGBI36T.ts} +4 -4
- package/dist/chunks/{chunk-EUMINX7L.ts → chunk-P7DD5PWZ.ts} +2 -2
- package/dist/chunks/{chunk-VJN7KU4J.ts → chunk-PG7NMFP2.ts} +83 -18
- package/dist/chunks/chunk-PG7NMFP2.ts.map +7 -0
- package/dist/chunks/{chunk-T3NC77TU.ts → chunk-R75IHAOJ.ts} +45 -18
- package/dist/chunks/chunk-R75IHAOJ.ts.map +7 -0
- package/dist/chunks/{chunk-4TQWFLOQ.ts → chunk-U3XRI2HF.ts} +28 -197
- package/dist/chunks/chunk-U3XRI2HF.ts.map +7 -0
- package/dist/chunks/chunk-WD6FJHCK.ts +140 -0
- package/dist/chunks/chunk-WD6FJHCK.ts.map +7 -0
- package/dist/chunks/{conflict-review-ZCGWFKEK.ts → conflict-review-T7GSOKNG.ts} +9 -8
- package/dist/chunks/{conflict-review-ZCGWFKEK.ts.map → conflict-review-T7GSOKNG.ts.map} +1 -1
- package/dist/chunks/{continuous-record-LFRVXQBD.ts → continuous-record-SQJ47CXN.ts} +3 -3
- package/dist/chunks/{continuous-sync-LAIMSW7D.ts → continuous-sync-U4HIKPQK.ts} +9 -9
- package/dist/chunks/{continuous-sync-LAIMSW7D.ts.map → continuous-sync-U4HIKPQK.ts.map} +2 -2
- package/dist/chunks/{git-backend-KYDZ673X.ts → git-backend-NYKSG5X6.ts} +4 -4
- package/dist/chunks/{manager-result-dispatcher-AGBBVDDS.ts → manager-result-dispatcher-HOCM2GDA.ts} +4 -4
- package/dist/chunks/{manager-ui-PSA2L6CE.ts → manager-ui-FXXO6TW4.ts} +54 -17
- package/dist/chunks/manager-ui-FXXO6TW4.ts.map +7 -0
- package/dist/chunks/{remote-selection-ui-5725JDNC.ts → remote-selection-ui-YFPGYGNE.ts} +3 -3
- package/dist/chunks/{s3-backend-Y7WSCLJI.ts → s3-backend-F7PR5FB3.ts} +4 -4
- package/dist/chunks/{setup-wizard-SOYOJQKP.ts → setup-wizard-OVHZYXNW.ts} +2 -2
- package/dist/chunks/{sync-inspection-XFJOAMJD.ts → sync-inspection-5MY2HI4P.ts} +3 -3
- package/dist/chunks/{sync-operations-W5HFFMTP.ts → sync-operations-GNPS6BLY.ts} +29 -17
- package/dist/chunks/sync-operations-GNPS6BLY.ts.map +7 -0
- package/dist/chunks/{webdav-backend-6K26F6LT.ts → webdav-backend-TGFUH3SF.ts} +4 -4
- package/dist/index.ts +28 -23
- package/dist/index.ts.map +2 -2
- package/docs/agent-continuous-hardening.md +79 -0
- package/docs/agent-mode-verification.md +2 -0
- package/docs/agent-quality-verification.md +41 -0
- package/docs/continuous-sync-verification.md +2 -0
- package/package.json +2 -2
- package/skills/configure-pi-sync/references/configuration.md +21 -5
- package/src/commands/command-execution.ts +7 -2
- package/src/commands/command-handler.ts +3 -1
- package/src/commands/command-types.ts +3 -1
- package/src/sync/agent-plan.ts +26 -9
- package/src/sync/agent-refusal-retry.ts +54 -0
- package/src/sync/agent-request.ts +3 -1
- package/src/sync/agent-runner.ts +8 -6
- package/src/sync/agent-store.ts +32 -1
- package/src/sync/agent-sync.ts +63 -13
- package/src/sync/continuous-lease.ts +2 -7
- package/src/sync/continuous-record.ts +95 -13
- package/src/sync/continuous-storage.ts +12 -3
- package/src/sync/continuous-sync.ts +2 -1
- package/src/sync/merged-sync.ts +37 -9
- package/src/sync/sync-mutations.ts +11 -0
- package/src/ui/cancellable-operation.ts +2 -0
- package/src/ui/manager-state.ts +1 -0
- package/src/ui/manager-ui.ts +59 -14
- package/src/ui/secret-input.ts +29 -211
- package/dist/chunks/agent-plan-K3CRYVXV.ts.map +0 -7
- package/dist/chunks/agent-runner-E2JU2VZ6.ts.map +0 -7
- package/dist/chunks/agent-store-A3F6IYWZ.ts.map +0 -7
- package/dist/chunks/chunk-4TQWFLOQ.ts.map +0 -7
- package/dist/chunks/chunk-QGK445K7.ts.map +0 -7
- package/dist/chunks/chunk-QOC47PBC.ts +0 -97
- package/dist/chunks/chunk-QOC47PBC.ts.map +0 -7
- package/dist/chunks/chunk-T3NC77TU.ts.map +0 -7
- package/dist/chunks/chunk-VJN7KU4J.ts.map +0 -7
- package/dist/chunks/manager-ui-PSA2L6CE.ts.map +0 -7
- package/dist/chunks/sync-operations-W5HFFMTP.ts.map +0 -7
- /package/dist/chunks/{chunk-OWBAZA6J.ts.map → chunk-DT6TOCIG.ts.map} +0 -0
- /package/dist/chunks/{chunk-OEWQVWLC.ts.map → chunk-ORGBI36T.ts.map} +0 -0
- /package/dist/chunks/{chunk-EUMINX7L.ts.map → chunk-P7DD5PWZ.ts.map} +0 -0
- /package/dist/chunks/{continuous-record-LFRVXQBD.ts.map → continuous-record-SQJ47CXN.ts.map} +0 -0
- /package/dist/chunks/{git-backend-KYDZ673X.ts.map → git-backend-NYKSG5X6.ts.map} +0 -0
- /package/dist/chunks/{manager-result-dispatcher-AGBBVDDS.ts.map → manager-result-dispatcher-HOCM2GDA.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-5725JDNC.ts.map → remote-selection-ui-YFPGYGNE.ts.map} +0 -0
- /package/dist/chunks/{s3-backend-Y7WSCLJI.ts.map → s3-backend-F7PR5FB3.ts.map} +0 -0
- /package/dist/chunks/{setup-wizard-SOYOJQKP.ts.map → setup-wizard-OVHZYXNW.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-XFJOAMJD.ts.map → sync-inspection-5MY2HI4P.ts.map} +0 -0
- /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
|
|
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,
|
|
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
|
|
package/src/sync/agent-plan.ts
CHANGED
|
@@ -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)
|
|
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)
|
|
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
|
-
|
|
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
|
|
89
|
+
const current = await createSnapshot(config.snapshotIdentity, { ...snapshotOptions, signal });
|
|
90
|
+
await check();
|
|
74
91
|
const currentState = await readStateForConfig(config);
|
|
75
|
-
await
|
|
76
|
-
const currentHead = await backend.readHead(
|
|
77
|
-
await
|
|
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";
|
package/src/sync/agent-runner.ts
CHANGED
|
@@ -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
|
-
|
|
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" ||
|
package/src/sync/agent-store.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/sync/agent-sync.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
44
|
+
let expectedRefusal: ReturnType<typeof parseAgentCandidate> | undefined;
|
|
45
|
+
const validate = (signal = options.signal) =>
|
|
40
46
|
withLock("agent-validate", async () => {
|
|
41
|
-
|
|
47
|
+
signal?.throwIfAborted();
|
|
42
48
|
await assertOwner();
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
await
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
}
|