@awak-app/simy-cli 0.6.12 → 0.6.16
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 +35 -0
- package/package.json +1 -1
- package/src/agent.js +12 -0
- package/src/auto-update.js +42 -5
- package/src/cli-contract.js +1 -1
- package/src/desktop-access.js +3 -1
- package/src/desktop-executor.js +24 -10
- package/src/desktop-host.js +52 -15
- package/src/desktop-update-stop.js +22 -0
- package/src/orchestrator/audit.js +2 -2
- package/src/orchestrator/contract.js +2 -0
- package/src/orchestrator/independent-audit.js +11 -3
- package/src/orchestrator/instruction.js +3 -3
- package/src/orchestrator/loop.js +2 -1
- package/src/orchestrator/problem-solving.js +7 -6
- package/src/orchestrator/result.js +104 -8
- package/src/provider-model-selection.js +24 -0
- package/src/runner.js +16 -30
- package/src/session-supervision/action-association-host.js +23 -10
- package/src/session-supervision/association-refresh.js +12 -0
- package/src/session-supervision/association-store.js +26 -10
package/README.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Local execution agent for SIMY Local Tasks and Agentic Loop runs.
|
|
4
4
|
|
|
5
|
+
Action candidate search runs once for each authenticated live conversation context,
|
|
6
|
+
and again when that context changes or **Refresh** is selected. Background binding
|
|
7
|
+
renewal uses the lightweight binding endpoint instead of repeating candidate search.
|
|
8
|
+
Settled ordinary conversations and terminal managed tasks do not automatically
|
|
9
|
+
search candidates. Bound Actions still renew to reflect changes to deadlines and
|
|
10
|
+
manual links. Candidate text expires after one minute; select **Refresh** to retrieve
|
|
11
|
+
current suggestions after editing Actions in SIMY. Candidate search identities are
|
|
12
|
+
cached in memory, so restarting the host may retrieve live contexts again.
|
|
13
|
+
|
|
5
14
|
`simy --version` prints the installed CLI version without starting an agent or
|
|
6
15
|
checking for updates. `simy doctor` performs read-only local connection and
|
|
7
16
|
provider login checks and prints a JSON diagnostic report: installed/running
|
|
@@ -817,6 +826,32 @@ model responses or credentials. Historical summaries are not a replacement for
|
|
|
817
826
|
signed proof files or delivery acceptance. Records from versions that kept
|
|
818
827
|
analysis only in memory cannot be recovered after that process exits.
|
|
819
828
|
|
|
829
|
+
The CLI Agentic Loop uses [proportionate retry and review decisions](docs/stop-prevention/proportionate-continuation.md): reasoning notes are advisory, and an independent reviewer may explicitly defer or dismiss minor/warning/info findings with a nonempty explanation while retaining them in the receipt. Required acceptance evidence and major safety findings remain protected.
|
|
830
|
+
|
|
831
|
+
### Local coding attempt result collection
|
|
832
|
+
|
|
833
|
+
For `executeProcessAttempt`, native provider JSONL is read only from a successfully finished turn's final
|
|
834
|
+
assistant/result channel. Tool output and intermediate messages are never a
|
|
835
|
+
fallback result. Legacy line-anchored markers still work and may contain formatted
|
|
836
|
+
JSON; missing, ambiguous, truncated, invalid, oversized and failed-turn results
|
|
837
|
+
have distinct `result_parse.code` values. Invalid collection cannot become a
|
|
838
|
+
successful empty result. Plain-text markers are supported only for explicit
|
|
839
|
+
shell command overrides. This does not independently verify executor claims.
|
|
840
|
+
The parser can re-read a saved raw result without rerunning the provider.
|
|
841
|
+
Local Task and direct-task result parsers are separate boundaries and are not
|
|
842
|
+
covered by this coding-attempt guarantee.
|
|
843
|
+
|
|
844
|
+
### Explicit desktop provider selection
|
|
845
|
+
|
|
846
|
+
Local coding requests may specify `model` / `reasoning_effort` and independently
|
|
847
|
+
`audit_model` / `audit_reasoning_effort`. Both selections survive recovery.
|
|
848
|
+
Codex receives the exact model and reasoning config; the CLI does not use a
|
|
849
|
+
catalog to reject unknown model names or silently replace a rejected model.
|
|
850
|
+
Claude receives the exact model; explicit reasoning is unsupported by this
|
|
851
|
+
adapter and fails before dispatch. A custom shell override cannot enforce an
|
|
852
|
+
explicit selection and is rejected instead of dropping it. Provider acceptance,
|
|
853
|
+
authentication and actual availability still require the provider's response.
|
|
854
|
+
This changes command construction only; it does not start any workflow.
|
|
820
855
|
|
|
821
856
|
### Reusing a running service and launch recovery
|
|
822
857
|
|
package/package.json
CHANGED
package/src/agent.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeProviderModelSelection } from "./provider-model-selection.js";
|
|
1
2
|
import { assertConnectionScope, connectionScope, connectionDiagnostic } from "./connection-diagnostics.js";
|
|
2
3
|
import { authorizeLaunch } from "./launch-authorization.js";
|
|
3
4
|
import { normalizePurposeVerification, assertPurposeActor, preparePurposeVerification, verifyPurposeLocalTask, purposeSigningKeyPath, purposeVerificationCapability, purposeWorkspaceBusy } from "./purpose-local-proof.js";
|
|
@@ -1998,6 +1999,13 @@ export async function startAgent({
|
|
|
1998
1999
|
return;
|
|
1999
2000
|
}
|
|
2000
2001
|
const { body, attachments } = await readCodingLoopStart(req);
|
|
2002
|
+
let providerSelection;
|
|
2003
|
+
try {
|
|
2004
|
+
providerSelection = normalizeProviderModelSelection(body);
|
|
2005
|
+
} catch (error) {
|
|
2006
|
+
json(res, 400, { error: error.message, code: "invalid_provider_selection" });
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2001
2009
|
const guardrailSource = String(body.source_message || "").trim();
|
|
2002
2010
|
if (guardrailSource) {
|
|
2003
2011
|
const guardrail = classifyExecutionRequirement(guardrailSource);
|
|
@@ -2101,6 +2109,7 @@ export async function startAgent({
|
|
|
2101
2109
|
const run = createRun({
|
|
2102
2110
|
runId,
|
|
2103
2111
|
request: {
|
|
2112
|
+
...providerSelection,
|
|
2104
2113
|
backend: body.backend === "claude" ? "claude" : "codex",
|
|
2105
2114
|
execution_target: executionTarget,
|
|
2106
2115
|
execution_device_id: session?.device_id || null,
|
|
@@ -2752,6 +2761,7 @@ export async function startAgent({
|
|
|
2752
2761
|
scanRepositories,
|
|
2753
2762
|
selectRepository,
|
|
2754
2763
|
create: (input) => withUpdateWork(updateManager, async () => {
|
|
2764
|
+
const providerSelection = normalizeProviderModelSelection(input);
|
|
2755
2765
|
const scope = await authorizeDesktopWork();
|
|
2756
2766
|
if (!isSessionValid(session, Date.now(), apiOrigin)) {
|
|
2757
2767
|
throw new Error("Sign in to SIMY before starting a coding task.");
|
|
@@ -2767,6 +2777,8 @@ export async function startAgent({
|
|
|
2767
2777
|
}
|
|
2768
2778
|
const indexedRepository = await ensureRepositoryIndexed(repository);
|
|
2769
2779
|
const request = {
|
|
2780
|
+
...providerSelection,
|
|
2781
|
+
audit_backend: input.audit_backend === "claude" || input.audit_backend === "codex" ? input.audit_backend : null,
|
|
2770
2782
|
requirement,
|
|
2771
2783
|
repository,
|
|
2772
2784
|
local_path: indexedRepository?.local_path || input.localPath || workspace.localPath,
|
package/src/auto-update.js
CHANGED
|
@@ -69,6 +69,9 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
69
69
|
#stopped = false;
|
|
70
70
|
#registryListener = null;
|
|
71
71
|
#activeWork = 0;
|
|
72
|
+
#confirmedUpdate = null;
|
|
73
|
+
#confirmedReady = false;
|
|
74
|
+
#desktopIdle = () => true;
|
|
72
75
|
|
|
73
76
|
constructor(options) {
|
|
74
77
|
super();
|
|
@@ -95,7 +98,39 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
snapshot() {
|
|
98
|
-
return structuredClone(this.#snapshot);
|
|
101
|
+
return structuredClone({ ...this.#snapshot, requires_task_stop: !this.#isIdle() });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Close admission before cancellation begins. The same transaction owns the
|
|
105
|
+
// idle check and installation, so a releasing worker cannot race the stop.
|
|
106
|
+
applyConfirmed(stopWork) {
|
|
107
|
+
if (this.#confirmedUpdate) return this.#confirmedUpdate;
|
|
108
|
+
if (this.#applying) return this.#applying;
|
|
109
|
+
if (!this.#snapshot.target_version || !this.#options.lastRelease) {
|
|
110
|
+
return Promise.reject(new Error('Check for an available update first.'));
|
|
111
|
+
}
|
|
112
|
+
this.#setState('stopping_tasks', { message: 'Stopping unfinished tasks before updating…', error: null });
|
|
113
|
+
this.#confirmedUpdate = (async () => {
|
|
114
|
+
try {
|
|
115
|
+
await Promise.resolve().then(stopWork);
|
|
116
|
+
await this.#checking;
|
|
117
|
+
if (!this.#snapshot.target_version) throw new Error('The update is no longer available.');
|
|
118
|
+
if (!this.#isIdle()) throw new Error('Task shutdown did not finish. Restart SIMY and retry the update.');
|
|
119
|
+
if (!this.#snapshot.automatic || classifyVersionChange(this.#options.currentVersion, this.#snapshot.target_version) === 'major') {
|
|
120
|
+
this.#setState('update_available', { message: 'Tasks stopped. Opening the update installer…' });
|
|
121
|
+
await this.performAction();
|
|
122
|
+
return this.snapshot();
|
|
123
|
+
}
|
|
124
|
+
this.#confirmedReady = true;
|
|
125
|
+
this.#setState('waiting_for_idle', { message: 'Tasks stopped. Installing the update…' });
|
|
126
|
+
return await this.maybeApply();
|
|
127
|
+
} catch (error) {
|
|
128
|
+
this.#setState('failed', { message: 'Update could not finish after stopping tasks. Restart SIMY and retry.',
|
|
129
|
+
error: errorMessage(error), action: updateAction(this.#snapshot.target_version, this.#options) });
|
|
130
|
+
return this.snapshot();
|
|
131
|
+
} finally { this.#confirmedUpdate = null; this.#confirmedReady = false; }
|
|
132
|
+
})();
|
|
133
|
+
return this.#confirmedUpdate;
|
|
99
134
|
}
|
|
100
135
|
|
|
101
136
|
async performAction() {
|
|
@@ -110,7 +145,7 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
110
145
|
}
|
|
111
146
|
|
|
112
147
|
acceptsNewWork() {
|
|
113
|
-
return !["updating", "restarting"].includes(this.#snapshot.state);
|
|
148
|
+
return !this.#confirmedUpdate && !["stopping_tasks", "updating", "restarting"].includes(this.#snapshot.state);
|
|
114
149
|
}
|
|
115
150
|
|
|
116
151
|
beginWork() {
|
|
@@ -127,10 +162,11 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
127
162
|
};
|
|
128
163
|
}
|
|
129
164
|
|
|
130
|
-
start({ registry, restartDaemon } = {}) {
|
|
165
|
+
start({ registry, restartDaemon, isDesktopIdle = () => true } = {}) {
|
|
131
166
|
if (this.#snapshot.state === "disabled" || this.#checkTimer || this.#stopped) return this;
|
|
132
167
|
this.#registry = registry;
|
|
133
168
|
this.#restartDaemon = restartDaemon;
|
|
169
|
+
this.#desktopIdle = isDesktopIdle;
|
|
134
170
|
this.#registryListener = () => {
|
|
135
171
|
if (this.#snapshot.state === "waiting_for_idle") void this.maybeApply();
|
|
136
172
|
};
|
|
@@ -156,7 +192,7 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
156
192
|
async checkNow() {
|
|
157
193
|
if (this.#stopped || this.#snapshot.state === "disabled") return this.snapshot();
|
|
158
194
|
if (this.#checking) return this.#checking;
|
|
159
|
-
if (["updating", "restarting"].includes(this.#snapshot.state)) return this.snapshot();
|
|
195
|
+
if (["stopping_tasks", "updating", "restarting"].includes(this.#snapshot.state) || this.#confirmedUpdate) return this.snapshot();
|
|
160
196
|
|
|
161
197
|
this.#checking = this.#performCheck().finally(() => {
|
|
162
198
|
this.#checking = null;
|
|
@@ -237,6 +273,7 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
237
273
|
}
|
|
238
274
|
|
|
239
275
|
async maybeApply() {
|
|
276
|
+
if (this.#confirmedUpdate && !this.#confirmedReady) return this.snapshot();
|
|
240
277
|
if (this.#stopped || this.#snapshot.state !== "waiting_for_idle") {
|
|
241
278
|
return this.snapshot();
|
|
242
279
|
}
|
|
@@ -333,7 +370,7 @@ export class CliAutoUpdater extends EventEmitter {
|
|
|
333
370
|
}
|
|
334
371
|
|
|
335
372
|
#isIdle() {
|
|
336
|
-
return this.#activeWork === 0 && isRegistryIdle(this.#registry);
|
|
373
|
+
return this.#activeWork === 0 && isRegistryIdle(this.#registry) && this.#desktopIdle();
|
|
337
374
|
}
|
|
338
375
|
|
|
339
376
|
#clearIdleTimer() {
|
package/src/cli-contract.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "./durable-local-task-steps-contract.js";
|
|
14
14
|
import { buildExecutionCapabilityContract } from "./execution-capability-contract.js";
|
|
15
15
|
|
|
16
|
-
export const CLI_VERSION = "0.6.
|
|
16
|
+
export const CLI_VERSION = "0.6.16";
|
|
17
17
|
export const CLI_API_CONTRACT_VERSION = 10;
|
|
18
18
|
|
|
19
19
|
export function withCliContract(
|
package/src/desktop-access.js
CHANGED
|
@@ -52,7 +52,9 @@ export function createDesktopAccessPolicy({ readSession, isValid, fetchAccess, i
|
|
|
52
52
|
result = { state: payload.state,
|
|
53
53
|
organizationId: payload.organizationId, organizationName: payload.organizationName.slice(0, 120), organizationSlug: payload.organizationSlug, settingsPath: payload.settingsPath, membersPath: payload.membersPath };
|
|
54
54
|
} catch (error) {
|
|
55
|
-
result = { ...base, state: error?.status === 401 ? 'signed_out' : 'unavailable'
|
|
55
|
+
result = { ...base, state: error?.status === 401 ? 'signed_out' : 'unavailable',
|
|
56
|
+
reason: error?.status === 401 ? 'authentication_required' :
|
|
57
|
+
['TimeoutError', 'AbortError'].includes(error?.name) ? 'timeout' : 'service_unavailable' };
|
|
56
58
|
}
|
|
57
59
|
// Late responses never authorize a new account or a switched organization.
|
|
58
60
|
if (readSession() === session && basic().state === 'checking') {
|
package/src/desktop-executor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import spawn from "cross-spawn";
|
|
2
|
+
import { normalizeProviderModelSelection } from "./provider-model-selection.js";
|
|
2
3
|
|
|
3
4
|
import {
|
|
4
5
|
BACKEND_VERSION_POLICIES,
|
|
@@ -48,6 +49,7 @@ export function desktopExecutorCapability(inspection) {
|
|
|
48
49
|
export async function resolveDesktopExecutorCommand({
|
|
49
50
|
backend,
|
|
50
51
|
model = null,
|
|
52
|
+
reasoningEffort = null,
|
|
51
53
|
instruction,
|
|
52
54
|
repositoryPath,
|
|
53
55
|
executionKind = "agentic_loop",
|
|
@@ -62,17 +64,26 @@ export async function resolveDesktopExecutorCommand({
|
|
|
62
64
|
throw new Error("Desktop execution kind must be agentic_loop or direct.");
|
|
63
65
|
}
|
|
64
66
|
const normalizedPermissionMode = normalizePermissionMode(permissionMode);
|
|
65
|
-
const normalizedModel =
|
|
66
|
-
model
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
throw
|
|
67
|
+
const { model: normalizedModel, reasoning_effort: effort } =
|
|
68
|
+
normalizeProviderModelSelection({ model, reasoning_effort: reasoningEffort });
|
|
69
|
+
if (provider !== "codex" && effort !== null) {
|
|
70
|
+
const error = new Error("Explicit reasoning effort is not supported by this Claude command adapter.");
|
|
71
|
+
error.code = "desktop_executor_selection_unsupported";
|
|
72
|
+
throw error;
|
|
71
73
|
}
|
|
74
|
+
const selectionArgs = [
|
|
75
|
+
...(normalizedModel ? ["--model", normalizedModel] : []),
|
|
76
|
+
...(effort ? ["--config", `model_reasoning_effort=${JSON.stringify(effort)}`] : []),
|
|
77
|
+
];
|
|
72
78
|
const target = normalizeDesktopExecutionTarget(DESKTOP_EXECUTION_TARGET);
|
|
73
79
|
const override =
|
|
74
80
|
provider === "claude" ? environment.SIMY_CLAUDE_COMMAND : environment.SIMY_CODEX_COMMAND;
|
|
75
81
|
if (override) {
|
|
82
|
+
if (normalizedModel || effort) {
|
|
83
|
+
const error = new Error("Explicit provider selection cannot be enforced by a shell command override.");
|
|
84
|
+
error.code = "desktop_executor_selection_unsupported";
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
76
87
|
return {
|
|
77
88
|
...shellCommand(override, repositoryPath, instruction, spawnImpl),
|
|
78
89
|
backend: provider,
|
|
@@ -100,7 +111,7 @@ export async function resolveDesktopExecutorCommand({
|
|
|
100
111
|
normalizedPermissionMode,
|
|
101
112
|
normalizedModel,
|
|
102
113
|
)
|
|
103
|
-
: claudeBackendArgs(instruction)
|
|
114
|
+
: claudeBackendArgs(instruction, normalizedModel)
|
|
104
115
|
: directExecution
|
|
105
116
|
? [
|
|
106
117
|
"exec",
|
|
@@ -108,22 +119,24 @@ export async function resolveDesktopExecutorCommand({
|
|
|
108
119
|
"--skip-git-repo-check",
|
|
109
120
|
"--sandbox",
|
|
110
121
|
normalizedPermissionMode === "read_only" ? "read-only" : "workspace-write",
|
|
111
|
-
...
|
|
122
|
+
...selectionArgs,
|
|
112
123
|
instruction,
|
|
113
124
|
]
|
|
114
|
-
: ["exec", "--json", instruction],
|
|
125
|
+
: ["exec", "--json", ...selectionArgs, instruction],
|
|
115
126
|
env: {},
|
|
116
127
|
spawn: spawnImpl,
|
|
117
128
|
backend: provider,
|
|
118
129
|
execution_target: target,
|
|
119
130
|
execution_kind: executionKind,
|
|
120
131
|
permission_mode: normalizedPermissionMode,
|
|
132
|
+
requested_model: normalizedModel,
|
|
133
|
+
requested_reasoning_effort: effort,
|
|
121
134
|
verification: "compatible_version",
|
|
122
135
|
installed_version: inspection.installed_version,
|
|
123
136
|
};
|
|
124
137
|
}
|
|
125
138
|
|
|
126
|
-
export function claudeBackendArgs(instruction) {
|
|
139
|
+
export function claudeBackendArgs(instruction, model = null) {
|
|
127
140
|
return [
|
|
128
141
|
"-p",
|
|
129
142
|
instruction,
|
|
@@ -134,6 +147,7 @@ export function claudeBackendArgs(instruction) {
|
|
|
134
147
|
// by the user. Print mode cannot display permission prompts, so edits would
|
|
135
148
|
// otherwise be silently unavailable to the desktop executor.
|
|
136
149
|
"--dangerously-skip-permissions",
|
|
150
|
+
...(model ? ["--model", model] : []),
|
|
137
151
|
];
|
|
138
152
|
}
|
|
139
153
|
|
package/src/desktop-host.js
CHANGED
|
@@ -4,10 +4,11 @@ import { inspectProviderPlugins, unavailablePluginStatus } from './sqm/provider-
|
|
|
4
4
|
import { openCodexAppServer } from './session-supervision/codex-app-server.js';
|
|
5
5
|
import { conversationSqmChecks } from './session-supervision/sqm-presentation.js';
|
|
6
6
|
import { stopOwnedDesktopHost } from "./desktop-force-quit.js";
|
|
7
|
+
import { stopDesktopWorkForUpdate } from './desktop-update-stop.js';
|
|
7
8
|
import { desktopAccessReady, desktopActionAllowed, requireDesktopAccess, validDesktopAccessPath } from './desktop-access.js';
|
|
8
9
|
import { createActionDeadlineService } from './session-supervision/action-deadline.js';
|
|
9
10
|
import { deliveryHookStatus, openHookSetup } from "./delivery-hooks/desktop.js";
|
|
10
|
-
import { controlDelivery, verifyDelivery, createRecordReader } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
11
|
+
import { controlDelivery, verifyDelivery, createRecordReader, TERMINAL } from "./codex-plugins/simy/hooks/delivery-loop.mjs";
|
|
11
12
|
import { createManagedSupervisor } from "./delivery-hooks/supervisor.js";
|
|
12
13
|
import { createActionAssociationHost } from "./session-supervision/action-association-host.js";
|
|
13
14
|
import { createStopNotifier } from "./session-supervision/stop-notifications.js";
|
|
@@ -63,6 +64,7 @@ function desktopActionError(action, error) {
|
|
|
63
64
|
if (["sqm_set_enabled", "sqm_set_engine_fallback", "sqm_retry_environment"].includes(action)) {
|
|
64
65
|
return "Could not save SQM settings. Check your SIMY connection and try again.";
|
|
65
66
|
}
|
|
67
|
+
if (action === 'update') return 'Update could not finish. Restart SIMY and retry; stopped tasks stay stopped.';
|
|
66
68
|
return "Action could not finish. Refresh the status and try again.";
|
|
67
69
|
}
|
|
68
70
|
|
|
@@ -115,14 +117,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
115
117
|
};
|
|
116
118
|
const accessSnapshot = async () => setAccessSnapshot((await agent?.getDesktopStatus?.())?.access);
|
|
117
119
|
let stoppingForUpdate = false;
|
|
120
|
+
let unfinishedDesktopWork = true;
|
|
118
121
|
let organizationAnalysesSuspended = false;
|
|
119
122
|
let organizationAnalysisDrain = Promise.resolve();
|
|
120
123
|
let organizationAnalysisEpoch = 0;
|
|
121
124
|
const authorizeWork = async expectedScope => {
|
|
122
|
-
if (closed || stoppingForUpdate || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
|
|
125
|
+
if (closed || stoppingForUpdate || updater?.acceptsNewWork?.() === false || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
|
|
123
126
|
if (agent?.requireDesktopAccess) await agent.requireDesktopAccess();
|
|
124
127
|
const access = await accessSnapshot();
|
|
125
|
-
if (closed || stoppingForUpdate || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
|
|
128
|
+
if (closed || stoppingForUpdate || updater?.acceptsNewWork?.() === false || organizationAnalysesSuspended) throw new Error("SIMY is stopping. Reopen SIMY before starting new work.");
|
|
126
129
|
requireDesktopAccess(access);
|
|
127
130
|
const scope = accessScope(access);
|
|
128
131
|
if (expectedScope !== undefined && expectedScope !== scope) throw Object.assign(new Error("Desktop account or organization changed."), { code: "desktop_access_required" });
|
|
@@ -142,11 +145,37 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
142
145
|
let commands;
|
|
143
146
|
let sessionStatus = { sessions: [], providers: { codex: { discovery: "loading", reason: null }, claude: { discovery: "loading", reason: null } }, refreshedAt: null, initialLoading: true };
|
|
144
147
|
let sessionRefresh = null, sessionRefreshedAt = 0, sessionRefreshAgain = false, discoveryController = null, discoveryStopped = false;
|
|
148
|
+
const hasUnfinishedSessions = records => records.some(record => record.autoContinue === true || record.run && !TERMINAL.has(record.run.state)) ||
|
|
149
|
+
sessionStatus.sessions.some(candidate => candidate.autoContinue === true || ['queued', 'analyzing'].includes(analysisManager.project(candidate).analysis?.state));
|
|
145
150
|
const stopDiscovery = async ({ drain = false } = {}) => {
|
|
146
151
|
discoveryController?.abort();
|
|
147
152
|
sessionRefreshAgain = false;
|
|
148
153
|
if (drain && !dependencies.discoverSessions) await sessionRefresh;
|
|
149
154
|
};
|
|
155
|
+
let confirmedUpdateStop;
|
|
156
|
+
const stopWorkForUpdate = () => confirmedUpdateStop ||= (async () => {
|
|
157
|
+
stoppingForUpdate = true; discoveryStopped = true;
|
|
158
|
+
void stopDiscovery();
|
|
159
|
+
await stopDesktopWorkForUpdate({
|
|
160
|
+
stopWork: () => Promise.all([
|
|
161
|
+
agent.stopDesktopTasks(), managedSupervisor.close(),
|
|
162
|
+
automaticContinuation?.close(), analysisManager?.close(),
|
|
163
|
+
(async () => {
|
|
164
|
+
const records = await recordReader.list();
|
|
165
|
+
await Promise.all(records.map(async record => {
|
|
166
|
+
if (record.autoContinue === true) await (dependencies.setSessionAutoContinue || setSessionAutoContinue)({
|
|
167
|
+
root: deliveryRoot, provider: record.provider, session: record.session, enabled: false });
|
|
168
|
+
if (record.run && !TERMINAL.has(record.run.state)) await controlDelivery({ root: deliveryRoot,
|
|
169
|
+
provider: record.provider, session: record.session, taskId: record.run.managed?.taskId, action: 'stop' });
|
|
170
|
+
}));
|
|
171
|
+
})(),
|
|
172
|
+
]),
|
|
173
|
+
killOwned: () => (dependencies.stopOwnedHost || stopOwnedDesktopHost)(process.pid,
|
|
174
|
+
{ parentPid: process.ppid, includeRoot: false }),
|
|
175
|
+
timeoutMs: dependencies.updateStopTimeoutMs ?? 12_000,
|
|
176
|
+
});
|
|
177
|
+
unfinishedDesktopWork = false;
|
|
178
|
+
})();
|
|
150
179
|
const refreshSessions = (force = false) => {
|
|
151
180
|
if (discoveryStopped && !closed && !relay) return sessionStatus;
|
|
152
181
|
if (closed || relay || !desktopAccessReady(currentAccess)) return { sessions: [], providers: {}, refreshedAt: null };
|
|
@@ -277,6 +306,11 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
277
306
|
const requestedScope = accessScope(currentAccess);
|
|
278
307
|
status.pluginSetup = { ...pluginSetup };
|
|
279
308
|
status.providerPlugins = structuredClone(providerPlugins);
|
|
309
|
+
const records = await recordReader.list();
|
|
310
|
+
const update = status.update || updater.snapshot?.() || {};
|
|
311
|
+
if (!stoppingForUpdate) unfinishedDesktopWork = hasUnfinishedSessions(records);
|
|
312
|
+
status.update = { ...update, requires_task_stop: update.requires_task_stop === true ||
|
|
313
|
+
(status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 || hasUnfinishedSessions(records) };
|
|
280
314
|
if (!desktopAccessReady(currentAccess)) {
|
|
281
315
|
for (const item of sessionStatus.sessions) automaticContinuation?.revoke(item.provider, item.session);
|
|
282
316
|
if (currentAccess.state !== 'checking') await analysisManager.update([]);
|
|
@@ -289,7 +323,6 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
289
323
|
return;
|
|
290
324
|
}
|
|
291
325
|
await analysisManager.updateProviderStatuses?.(status.providers);
|
|
292
|
-
const records = await recordReader.list();
|
|
293
326
|
stopNotifier.observe(records);
|
|
294
327
|
const projection = await actionAssociations.beginProjection?.({ records });
|
|
295
328
|
status.deliveryHooks = await (dependencies.deliveryHookStatus || deliveryHookStatus)({ root: deliveryRoot, records: projection?.records || records }).catch(() => ({ error: "Delivery hook status unavailable", providers: {}, runs: [] }));
|
|
@@ -378,7 +411,8 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
378
411
|
});
|
|
379
412
|
stopWorkflows = (dependencies.startWorkflowUpdates || startWorkflowUpdates)({ enabled: env.SIMY_AUTO_UPDATE !== "0" });
|
|
380
413
|
updater.on?.("change", () => { void publish().catch(() => {}); });
|
|
381
|
-
updater.start({ registry: agent.registry,
|
|
414
|
+
updater.start({ registry: agent.registry, isDesktopIdle: () => !unfinishedDesktopWork,
|
|
415
|
+
restartDaemon: async (targetVersion, installation) => {
|
|
382
416
|
if (!installation?.entryPath || !installation?.executablePath) throw new Error("Signed update runtime is unavailable.");
|
|
383
417
|
discoveryStopped = true;
|
|
384
418
|
await stopDiscovery({ drain: true });
|
|
@@ -499,6 +533,10 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
499
533
|
try {
|
|
500
534
|
const action = command.action;
|
|
501
535
|
if (action === "quit") {
|
|
536
|
+
if (command.forUpdate === true) {
|
|
537
|
+
if (command.force !== true) throw new Error('Confirm before stopping unfinished tasks.');
|
|
538
|
+
await stopWorkForUpdate();
|
|
539
|
+
}
|
|
502
540
|
if (command.force !== true) {
|
|
503
541
|
const status = await agent.getDesktopStatus();
|
|
504
542
|
if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0) throw new Error("Tasks are still running. Confirm before quitting.");
|
|
@@ -595,16 +633,15 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
595
633
|
}
|
|
596
634
|
} else if (action === "check_update") await updater.checkNow();
|
|
597
635
|
else if (action === "update") {
|
|
598
|
-
if (command.force === true)
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
636
|
+
if (command.force === true && updater.applyConfirmed) await updater.applyConfirmed(stopWorkForUpdate);
|
|
637
|
+
else if (command.force === true) { await stopWorkForUpdate(); await updater.performAction(); }
|
|
638
|
+
else {
|
|
639
|
+
const status = await agent.getDesktopStatus();
|
|
640
|
+
if ((status.tasks?.activeCount || 0) + (status.sqmTasks?.activeCount || 0) > 0 ||
|
|
641
|
+
updater.snapshot().requires_task_stop === true || hasUnfinishedSessions(await recordReader.list())) throw new Error('Confirm before stopping unfinished tasks.');
|
|
642
|
+
if (updater.snapshot().state === "waiting_for_idle") await updater.maybeApply();
|
|
643
|
+
else await updater.performAction();
|
|
605
644
|
}
|
|
606
|
-
if (updater.snapshot().state === "waiting_for_idle") await updater.maybeApply();
|
|
607
|
-
else await updater.performAction();
|
|
608
645
|
if (command.force === true && ["failed", "error"].includes(updater.snapshot().state)) throw new Error("Update failed after stopping tasks.");
|
|
609
646
|
} else if (action === "codex_login" || action === "claude_login") {
|
|
610
647
|
const provider = action === "claude_login" ? "claude" : "codex";
|
|
@@ -710,7 +747,7 @@ export async function startDesktopHost({ input = process.stdin, output = process
|
|
|
710
747
|
emit({ type: "result", id: command.id, ok: false, message: command.action === "update" && stoppingForUpdate ? "Could not finish the update after stopping tasks. Reopen SIMY and retry the update." : desktopActionError(command.action, error) });
|
|
711
748
|
}
|
|
712
749
|
};
|
|
713
|
-
if (command.action === "quit") {
|
|
750
|
+
if (command.action === "quit" || command.action === "update" && command.force === true) {
|
|
714
751
|
void execute();
|
|
715
752
|
} else if (["login", "organization_select", "organization_cancel", "authorization_open"].includes(command.action)) {
|
|
716
753
|
authorizationQueue = authorizationQueue.then(execute).catch(() => {});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Confirmed updates must not wait indefinitely on a provider or remote flush.
|
|
2
|
+
// SIGKILL is ownership checked by the caller; the desktop host itself survives
|
|
3
|
+
// long enough to settle its local checkpoints and perform the signed handoff.
|
|
4
|
+
export async function stopDesktopWorkForUpdate({ stopWork, killOwned, timeoutMs = 12_000 }) {
|
|
5
|
+
let timer;
|
|
6
|
+
const stopping = Promise.resolve().then(stopWork);
|
|
7
|
+
// A kill failure or deadline can win the race before checkpoint cancellation
|
|
8
|
+
// settles. Observe its eventual rejection as well.
|
|
9
|
+
void stopping.catch(() => {});
|
|
10
|
+
const deadline = new Promise((_, reject) => {
|
|
11
|
+
timer = setTimeout(() => reject(new Error('Task shutdown timed out. Restart SIMY and retry the update.')), timeoutMs);
|
|
12
|
+
});
|
|
13
|
+
try {
|
|
14
|
+
await Promise.race([(async () => {
|
|
15
|
+
// Start cancellation before taking the process snapshot so queued work is
|
|
16
|
+
// fenced and live workers observe their stop intent before termination.
|
|
17
|
+
await Promise.resolve();
|
|
18
|
+
await killOwned();
|
|
19
|
+
await stopping;
|
|
20
|
+
})(), deadline]);
|
|
21
|
+
} finally { clearTimeout(timer); }
|
|
22
|
+
}
|
|
@@ -134,7 +134,7 @@ export async function auditAttempt(charter, attempt, { previousAttempt = null, s
|
|
|
134
134
|
|
|
135
135
|
const retryProblemSolving = evaluateRetryProblemSolving(attempt, previousAttempt);
|
|
136
136
|
for (const check of retryProblemSolving.checks) {
|
|
137
|
-
addCheck(check.id, check.label, check.passed, check.detail);
|
|
137
|
+
addCheck(check.id, check.label, check.passed, check.detail, check.severity);
|
|
138
138
|
}
|
|
139
139
|
attempt.retry_problem_solving = retryProblemSolving;
|
|
140
140
|
|
|
@@ -220,7 +220,7 @@ export async function auditAttempt(charter, attempt, { previousAttempt = null, s
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
const findings = checks
|
|
223
|
-
.filter((check) => !check.passed)
|
|
223
|
+
.filter((check) => !check.passed && check.severity !== "advisory")
|
|
224
224
|
.map((check) => ({
|
|
225
225
|
code: check.id.toUpperCase(),
|
|
226
226
|
title: check.label,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeProviderModelSelection } from "../provider-model-selection.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
|
|
3
4
|
import {
|
|
@@ -103,6 +104,7 @@ export function createCodingLoopSnapshot({ runId, request, metadata = {} }) {
|
|
|
103
104
|
charter: {
|
|
104
105
|
id: `${runId}_charter`,
|
|
105
106
|
requirement: cleanString(request.requirement),
|
|
107
|
+
...normalizeProviderModelSelection(request),
|
|
106
108
|
repository: cleanString(request.repository),
|
|
107
109
|
backend: request.backend === "claude" ? "claude" : "codex",
|
|
108
110
|
execution_target: executionTarget,
|
|
@@ -112,7 +112,7 @@ export function buildIndependentAuditInstruction(charter, attempt) {
|
|
|
112
112
|
"criterion_results must contain one entry per stable criterion ID with criterion_id, passed, verification_method, evidence_refs, and observed_result.",
|
|
113
113
|
"visual_review must contain inspected, artifacts, and acceptance_observations.",
|
|
114
114
|
"Only passed=false entries are findings. Omit passing checks, and never report passed=true evidence as a finding.",
|
|
115
|
-
"Use passed=false when a blocker or major finding remains, or when evidence is unavailable.",
|
|
115
|
+
"Use passed=false when a blocker or major finding remains, or when required evidence is unavailable. For minor/warning/info findings, choose disposition=fix, defer, or dismiss based on impact and reversibility. Include the reason in explanation. Deferring or dismissing a non-major finding does not require human input and must not hide it. Major/blocker findings cannot be deferred by this field.",
|
|
116
116
|
].join("\n"),
|
|
117
117
|
),
|
|
118
118
|
].join("\n\n");
|
|
@@ -165,6 +165,8 @@ export function buildIndependentAudit(execution, charter = null, attempt = null)
|
|
|
165
165
|
target: cleanString(item.target) || "pull_request",
|
|
166
166
|
explanation: cleanString(item.explanation) || "Independent audit finding.",
|
|
167
167
|
repairability: item.repairability === "manual" ? "manual" : "auto",
|
|
168
|
+
disposition: ["defer", "dismiss"].includes(item.disposition) && cleanString(item.explanation)
|
|
169
|
+
? item.disposition : "fix",
|
|
168
170
|
blocker_kind: ["machine", "human", "external"].includes(item.blocker_kind)
|
|
169
171
|
? item.blocker_kind : item.repairability === "manual" ? "human" : "machine",
|
|
170
172
|
auto_fix_hint: cleanString(item.auto_fix_hint) || null,
|
|
@@ -249,10 +251,16 @@ export function buildIndependentAudit(execution, charter = null, attempt = null)
|
|
|
249
251
|
findings.push({ passed: false, code: "USER_OUTCOME_UNPROVEN", severity: "blocker",
|
|
250
252
|
target: "user_outcome", explanation: "The original user goal lacks independently observed operation and postcondition evidence.", repairability: "auto" });
|
|
251
253
|
}
|
|
252
|
-
|
|
254
|
+
// Only the independent reviewer can defer/dismiss a non-major finding.
|
|
255
|
+
// Unknown severities, generated evidence failures and major findings remain blocking.
|
|
256
|
+
const followUps = findings.filter(item => ["minor", "warning", "info"].includes(item.severity) &&
|
|
257
|
+
["defer", "dismiss"].includes(item.disposition));
|
|
258
|
+
const blockingFindings = findings.filter(item => !followUps.includes(item));
|
|
259
|
+
const requiresHuman = blockingFindings.some((item) => item.repairability === "manual");
|
|
253
260
|
return {
|
|
254
261
|
...objectiveBinding,
|
|
255
|
-
passed: result.passed === true
|
|
262
|
+
passed: blockingFindings.length === 0 && (result.passed === true || followUps.length > 0),
|
|
263
|
+
follow_ups: followUps,
|
|
256
264
|
summary: cleanString(result.summary) || "Independent audit completed.",
|
|
257
265
|
findings,
|
|
258
266
|
criterion_results: criterionResults,
|
|
@@ -171,8 +171,8 @@ export function buildCodingInstruction(
|
|
|
171
171
|
[
|
|
172
172
|
"Treat implementation as hypothesis testing: state assumptions, list plausible causes or approaches, inspect discriminating evidence, then choose the smallest strategy supported by that evidence.",
|
|
173
173
|
attemptNumber > 1
|
|
174
|
-
? "This is a retry.
|
|
175
|
-
: "For the first attempt,
|
|
174
|
+
? "This is a retry. Repair clear failures directly; compare plausible causes when needed. Reasoning notes are advisory, not a prerequisite to editing."
|
|
175
|
+
: "For the first attempt, investigate material uncertainty before a risky operation; use a reasonable assumption for low-impact reversible choices and verify as you proceed.",
|
|
176
176
|
attemptNumber > 1
|
|
177
177
|
? "Explain exactly what changed from the previous attempt and why the new evidence supports this strategy."
|
|
178
178
|
: "Record the uncertainty, first verification, concrete evidence checked, observed result, selected hypothesis, and strategy in the structured result.",
|
|
@@ -244,7 +244,7 @@ export function buildPromptInterventions(
|
|
|
244
244
|
trigger: "repair_focus",
|
|
245
245
|
title: "Repair focus",
|
|
246
246
|
prompt:
|
|
247
|
-
"
|
|
247
|
+
"Repair clear failures directly and verify the change. Investigate competing causes only when uncertainty warrants it; do not invent eight hypotheses or change strategy merely to satisfy a form.",
|
|
248
248
|
evidence_required: [
|
|
249
249
|
"hypotheses",
|
|
250
250
|
"selected_hypothesis_id",
|
package/src/orchestrator/loop.js
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "./independent-audit.js";
|
|
11
11
|
import { buildCodingInstruction, buildPromptInterventions } from "./instruction.js";
|
|
12
12
|
import { canStartProvider, codingAttemptCeiling, refreshBudgetState } from "./budget.js";
|
|
13
|
-
import { buildAttempt, mergeTokenUsage } from "./result.js";
|
|
13
|
+
import { buildAttempt, mergeTokenUsage, normalizeResultParseDiagnostic } from "./result.js";
|
|
14
14
|
import { evaluateRetryCircuit } from "./retry.js";
|
|
15
15
|
import { assessOutcomeProgress, verifyObjective } from "./outcome-monitor.js";
|
|
16
16
|
import { appendEvent, publish } from "./shared.js";
|
|
@@ -291,6 +291,7 @@ async function runCodingLoopOwned({
|
|
|
291
291
|
return snapshot;
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
attempt.independent_audit.result_parse = normalizeResultParseDiagnostic(auditExecution.result_parse);
|
|
294
295
|
attempt.independent_audit.minimum_matrix_sha256 = reviewedMatrix;
|
|
295
296
|
attempt.independent_audit.evidence_identity_sha256 = reviewedEvidenceIdentity;
|
|
296
297
|
attempt.independent_audit.objective_digest = reviewedObjectiveDigest;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { cleanString, stringArray } from "./shared.js";
|
|
2
2
|
|
|
3
|
-
const MIN_RETRY_HYPOTHESES =
|
|
3
|
+
const MIN_RETRY_HYPOTHESES = 1;
|
|
4
4
|
|
|
5
5
|
export function normalizeProblemSolving(value) {
|
|
6
6
|
const record = value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
@@ -49,7 +49,7 @@ export function evaluateRetryProblemSolving(attempt, previousAttempt) {
|
|
|
49
49
|
const checks = [
|
|
50
50
|
{
|
|
51
51
|
id: "retry_hypotheses_expanded",
|
|
52
|
-
label: "Retry considers
|
|
52
|
+
label: "Retry considers a plausible explanation",
|
|
53
53
|
passed: distinctHypotheses >= MIN_RETRY_HYPOTHESES,
|
|
54
54
|
detail: `Expected at least ${MIN_RETRY_HYPOTHESES} distinct hypotheses; recorded ${distinctHypotheses}.`,
|
|
55
55
|
},
|
|
@@ -78,9 +78,10 @@ export function evaluateRetryProblemSolving(attempt, previousAttempt) {
|
|
|
78
78
|
},
|
|
79
79
|
];
|
|
80
80
|
return {
|
|
81
|
-
required:
|
|
82
|
-
passed:
|
|
83
|
-
checks,
|
|
81
|
+
required: false,
|
|
82
|
+
passed: true,
|
|
83
|
+
assessment_complete: checks.every((check) => check.passed),
|
|
84
|
+
checks: checks.map(check => ({ ...check, severity: "advisory" })),
|
|
84
85
|
current,
|
|
85
86
|
previous,
|
|
86
87
|
};
|
|
@@ -91,7 +92,7 @@ export function problemSolvingResultContract() {
|
|
|
91
92
|
"problem_solving must be an object with hypotheses, selected_hypothesis_id, evidence_checked, strategy, and changed_from_previous.",
|
|
92
93
|
"Each hypothesis must use {id, statement}; evidence_checked must list concrete observations or commands.",
|
|
93
94
|
"On attempt 1, record the assumptions, hypotheses considered, selected hypothesis, and implementation strategy; changed_from_previous may be null.",
|
|
94
|
-
|
|
95
|
+
"On retry, use reasoning proportional to the uncertainty. If the repair is clear, make it and verify it; do not invent a fixed number of hypotheses or change a working strategy merely to satisfy a form. For persistent unexplained failures, compare plausible causes and inspect discriminating evidence. These reasoning notes are advisory; unchanged failures are still subject to the retry circuit.",
|
|
95
96
|
].join("\n");
|
|
96
97
|
}
|
|
97
98
|
|
|
@@ -56,6 +56,7 @@ export function buildAttempt({ attemptNumber, charter, instruction, promptInterv
|
|
|
56
56
|
visual_qa_result: null,
|
|
57
57
|
release_gate: null,
|
|
58
58
|
executor_logs: stringArray(execution.logs).slice(-100),
|
|
59
|
+
result_parse: normalizeResultParseDiagnostic(execution.result_parse),
|
|
59
60
|
token_usage:
|
|
60
61
|
execution.tokenUsage && typeof execution.tokenUsage === "object"
|
|
61
62
|
? execution.tokenUsage
|
|
@@ -79,16 +80,104 @@ export function parseStructuredResult(text) {
|
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
export function parseStructuredMarker(text, marker) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
return diagnoseStructuredMarker(text, marker).value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Parse only a line-anchored result channel. Braces in JSON strings are not
|
|
87
|
+
// boundaries; JSON.parse remains responsible for syntax and object validation.
|
|
88
|
+
export function diagnoseStructuredMarker(text, marker, { maxBytes = 1024 * 1024 } = {}) {
|
|
89
|
+
const failure = code => ({ code, value: {} });
|
|
90
|
+
if (typeof text !== "string") return failure("missing_marker");
|
|
91
|
+
if (Buffer.byteLength(text, "utf8") > maxBytes) return failure("result_too_large");
|
|
92
|
+
const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
93
|
+
const matches = [...text.matchAll(new RegExp(`(?:^|\\n)[ \t]*${escaped}`, "g"))];
|
|
94
|
+
if (!matches.length) return failure("missing_marker");
|
|
95
|
+
if (matches.length !== 1) return failure("ambiguous_marker");
|
|
96
|
+
const payload = text.slice(matches[0].index + matches[0][0].length).trim();
|
|
97
|
+
if (!payload) return failure("truncated_json");
|
|
98
|
+
if (!payload.startsWith("{")) return failure("result_root_not_object");
|
|
99
|
+
let depth = 0, quoted = false, escapedChar = false, end = -1;
|
|
100
|
+
for (let index = 0; index < payload.length; index += 1) {
|
|
101
|
+
const char = payload[index];
|
|
102
|
+
if (quoted) {
|
|
103
|
+
if (escapedChar) escapedChar = false;
|
|
104
|
+
else if (char === "\\") escapedChar = true;
|
|
105
|
+
else if (char === '"') quoted = false;
|
|
106
|
+
} else if (char === '"') quoted = true;
|
|
107
|
+
else if (char === "{" || char === "[") depth += 1;
|
|
108
|
+
else if (char === "}" || char === "]") {
|
|
109
|
+
depth -= 1;
|
|
110
|
+
if (depth === 0) { end = index + 1; break; }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (end < 0) return failure("truncated_json");
|
|
114
|
+
if (payload.slice(end).trim()) return failure("trailing_result_content");
|
|
86
115
|
try {
|
|
87
|
-
const value = JSON.parse(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
return {};
|
|
116
|
+
const value = JSON.parse(payload.slice(0, end));
|
|
117
|
+
if (!value || Array.isArray(value) || typeof value !== "object") return failure("result_root_not_object");
|
|
118
|
+
if (!Object.keys(value).length) return failure("empty_result");
|
|
119
|
+
return { code: "parsed", value };
|
|
120
|
+
} catch { return failure("invalid_json"); }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Native JSONL admits results only from a successfully finished provider turn.
|
|
124
|
+
// Command output, echoed prompts and intermediate assistant items cannot be a
|
|
125
|
+
// fallback channel. Explicit shell overrides retain the documented legacy text.
|
|
126
|
+
export function parseProviderStructuredResult(raw, { backend, marker = "SIMY_RESULT_JSON:", allowPlainText = false,
|
|
127
|
+
maxBytes = 1024 * 1024, maxStreamBytes = 64 * 1024 * 1024 } = {}) {
|
|
128
|
+
const failure = code => ({ code, value: {} });
|
|
129
|
+
if (typeof raw !== "string") return failure("missing_provider_final");
|
|
130
|
+
if (Buffer.byteLength(raw, "utf8") > maxStreamBytes) return failure("provider_stream_too_large");
|
|
131
|
+
const events = [];
|
|
132
|
+
let malformedStream = false;
|
|
133
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
134
|
+
if (!line.trim()) continue;
|
|
135
|
+
try {
|
|
136
|
+
const event = JSON.parse(line);
|
|
137
|
+
if (event && typeof event === "object" && !Array.isArray(event) && typeof event.type === "string") {
|
|
138
|
+
// Tool payload size is independent of the bounded final result. Retain
|
|
139
|
+
// event identity/order, not arbitrary command output, during selection.
|
|
140
|
+
events.push(event.type === "result" || event.item?.type === "agent_message"
|
|
141
|
+
? event : { type: event.type, item: { type: event.item?.type } });
|
|
142
|
+
} else malformedStream = true;
|
|
143
|
+
}
|
|
144
|
+
catch { malformedStream = true; }
|
|
91
145
|
}
|
|
146
|
+
const nativeTypes = new Set(backend === "claude"
|
|
147
|
+
? ["system", "assistant", "user", "result", "stream_event", "tool_progress", "tool_use_summary", "rate_limit_event", "auth_status"]
|
|
148
|
+
: ["thread.started", "turn.started", "turn.completed", "turn.failed", "item.started", "item.updated", "item.completed", "error"]);
|
|
149
|
+
const native = events.some(e => nativeTypes.has(e.type));
|
|
150
|
+
// A discarded line could be a later error or unfinished turn. Native streams
|
|
151
|
+
// must stay intact; only an explicit non-native override admits plain text.
|
|
152
|
+
if (malformedStream && (native || !allowPlainText)) return failure("malformed_provider_stream");
|
|
153
|
+
if (!native) return allowPlainText ? diagnoseStructuredMarker(raw, marker, {maxBytes}) : failure("missing_provider_final");
|
|
154
|
+
if (backend === "claude") {
|
|
155
|
+
const results = events.filter(e => e.type === "result");
|
|
156
|
+
if (results.length > 1) return failure("contradictory_provider_terminal");
|
|
157
|
+
const final = results[0];
|
|
158
|
+
if (!final) return failure("missing_provider_final");
|
|
159
|
+
if (events.slice(events.indexOf(final) + 1).some(e => typeof e.type === "string")) return failure("unsettled_provider_turn");
|
|
160
|
+
if (final.is_error === true || String(final.subtype || "").startsWith("error")) return failure("provider_failed");
|
|
161
|
+
return diagnoseStructuredMarker(final.result, marker, {maxBytes});
|
|
162
|
+
}
|
|
163
|
+
const end = events.findLastIndex(e => e.type === "turn.completed" || e.type === "turn.failed");
|
|
164
|
+
if (end < 0) return failure("missing_provider_final");
|
|
165
|
+
if (events[end].type === "turn.failed") return failure("provider_failed");
|
|
166
|
+
const start = events.slice(0, end).findLastIndex(e => e.type === "turn.started");
|
|
167
|
+
const turn = events.slice(start + 1, end);
|
|
168
|
+
if (turn.some(e => e.type === "turn.failed" || e.type === "turn.completed")) {
|
|
169
|
+
return failure("contradictory_provider_terminal");
|
|
170
|
+
}
|
|
171
|
+
const finalIndex = turn.findLastIndex(e => e.type === "item.completed" &&
|
|
172
|
+
e.item?.type === "agent_message" && (!e.item.phase || e.item.phase === "final_answer"));
|
|
173
|
+
if (finalIndex < 0) return failure("missing_provider_final");
|
|
174
|
+
if (turn.slice(finalIndex + 1).some(e => typeof e.type === "string")) {
|
|
175
|
+
return failure("nonterminal_agent_message");
|
|
176
|
+
}
|
|
177
|
+
const final = turn[finalIndex].item;
|
|
178
|
+
// A later unsettled turn must not replay an earlier completed result.
|
|
179
|
+
if (events.slice(end + 1).some(e => typeof e.type === "string")) return failure("unsettled_provider_turn");
|
|
180
|
+
return diagnoseStructuredMarker(final.text, marker, {maxBytes});
|
|
92
181
|
}
|
|
93
182
|
|
|
94
183
|
function buildArtifactPreviews(result) {
|
|
@@ -125,3 +214,10 @@ function isUsageRecord(value) {
|
|
|
125
214
|
}
|
|
126
215
|
|
|
127
216
|
function safeDigest(compute) { try { return compute(); } catch { return null; } }
|
|
217
|
+
|
|
218
|
+
export function normalizeResultParseDiagnostic(value) {
|
|
219
|
+
if (!value) return null;
|
|
220
|
+
const codes = ["parsed", "missing_marker", "result_too_large", "ambiguous_marker", "truncated_json", "result_root_not_object", "trailing_result_content", "empty_result", "invalid_json", "missing_provider_final", "provider_failed", "unsettled_provider_turn", "nonterminal_agent_message", "contradictory_provider_terminal", "provider_stream_too_large", "malformed_provider_stream"];
|
|
221
|
+
return { code: codes.includes(value.code) ? value.code : "unknown_parse_diagnostic",
|
|
222
|
+
channel: ["override_or_native", "provider_final"].includes(value.channel) ? value.channel : "unknown" };
|
|
223
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const SELECTION_FIELDS = ["model", "reasoning_effort", "audit_model", "audit_reasoning_effort"];
|
|
2
|
+
const REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]);
|
|
3
|
+
|
|
4
|
+
// Preserve explicit selections at request boundaries; do not infer a model catalog.
|
|
5
|
+
export function normalizeProviderModelSelection(input = {}) {
|
|
6
|
+
const result = {};
|
|
7
|
+
for (const field of SELECTION_FIELDS) {
|
|
8
|
+
const value = input[field];
|
|
9
|
+
if (value == null) {
|
|
10
|
+
result[field] = null;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
14
|
+
throw new Error(`${field} must be a non-empty string.`);
|
|
15
|
+
}
|
|
16
|
+
const normalized = value.trim();
|
|
17
|
+
const valid = field.endsWith("reasoning_effort")
|
|
18
|
+
? REASONING_EFFORTS.has(normalized)
|
|
19
|
+
: /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,159}(?:\[1m\])?$/.test(normalized);
|
|
20
|
+
if (!valid) throw new Error(`${field} is invalid.`);
|
|
21
|
+
result[field] = normalized;
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
package/src/runner.js
CHANGED
|
@@ -8,11 +8,10 @@ import { promisify } from "node:util";
|
|
|
8
8
|
import {
|
|
9
9
|
collectPrEvidence,
|
|
10
10
|
createCodingLoopSnapshot,
|
|
11
|
-
parseStructuredMarker,
|
|
12
|
-
parseStructuredResult,
|
|
13
11
|
recheckPrReadiness,
|
|
14
12
|
runCodingLoop,
|
|
15
13
|
} from "./orchestrator/index.js";
|
|
14
|
+
import { parseProviderStructuredResult } from "./orchestrator/result.js";
|
|
16
15
|
import {
|
|
17
16
|
EXECUTION_IO_POLICY_VERSION,
|
|
18
17
|
executionTextSha256,
|
|
@@ -180,6 +179,10 @@ export function restoreRun({ snapshot, session, apiOrigin, localPath = null, loc
|
|
|
180
179
|
sqmHistoryRoot,
|
|
181
180
|
request: {
|
|
182
181
|
backend: charter.backend === "claude" ? "claude" : "codex",
|
|
182
|
+
model: charter.model || null,
|
|
183
|
+
reasoning_effort: charter.reasoning_effort || null,
|
|
184
|
+
audit_model: charter.audit_model || null,
|
|
185
|
+
audit_reasoning_effort: charter.audit_reasoning_effort || null,
|
|
183
186
|
execution_target: normalizeDesktopExecutionTarget(charter.execution_target),
|
|
184
187
|
execution_device_id:
|
|
185
188
|
String(snapshot.metadata?.execution_device_id || session?.device_id || "").trim() || null,
|
|
@@ -1012,18 +1015,21 @@ export async function executeProcessAttempt({
|
|
|
1012
1015
|
marker = "SIMY_RESULT_JSON:",
|
|
1013
1016
|
attemptNumber = null,
|
|
1014
1017
|
resolveCommandImpl = resolveDesktopExecutorCommand,
|
|
1018
|
+
charter = run?.snapshot?.charter,
|
|
1015
1019
|
onExecutionComplete = null,
|
|
1016
1020
|
}) {
|
|
1021
|
+
const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
|
|
1017
1022
|
const command = await resolveCommandImpl({
|
|
1018
1023
|
backend,
|
|
1019
1024
|
instruction,
|
|
1020
1025
|
repositoryPath,
|
|
1026
|
+
model: phase === "reviewer" ? charter?.audit_model : charter?.model,
|
|
1027
|
+
reasoningEffort: phase === "reviewer" ? charter?.audit_reasoning_effort : charter?.reasoning_effort,
|
|
1021
1028
|
});
|
|
1022
1029
|
const startedAt = new Date().toISOString();
|
|
1023
1030
|
const stdout = [];
|
|
1024
1031
|
const stderr = [];
|
|
1025
1032
|
const tokenUsageRecords = [];
|
|
1026
|
-
const phase = marker === "SIMY_AUDIT_JSON:" ? "reviewer" : "executor";
|
|
1027
1033
|
const initialBudget = budgetState(run.snapshot);
|
|
1028
1034
|
const invocationId = randomUUID();
|
|
1029
1035
|
const observationSender =
|
|
@@ -1106,18 +1112,17 @@ export async function executeProcessAttempt({
|
|
|
1106
1112
|
run.child = null;
|
|
1107
1113
|
await Promise.all(checkpointWrites);
|
|
1108
1114
|
const rawStdout = stdout.join("");
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
: (value) => parseStructuredMarker(value, marker);
|
|
1114
|
-
const result = parse(assistantText);
|
|
1115
|
+
const parsed = parseProviderStructuredResult(rawStdout, {
|
|
1116
|
+
backend, marker, allowPlainText: command.verification === "explicit_command_override",
|
|
1117
|
+
});
|
|
1118
|
+
const parseError = parsed.code === "parsed" ? null : `Provider result unavailable: ${parsed.code}.`;
|
|
1115
1119
|
const execution = {
|
|
1116
1120
|
invocationId,
|
|
1117
1121
|
exitCode: terminalError && exitCode === 0 ? 1 : exitCode,
|
|
1118
|
-
error: error || terminalError,
|
|
1122
|
+
error: error || terminalError || parseError,
|
|
1119
1123
|
...(terminalErrorCode ? { errorCode: terminalErrorCode } : {}),
|
|
1120
|
-
result:
|
|
1124
|
+
result: parsed.value,
|
|
1125
|
+
result_parse: { code: parsed.code, channel: command.verification === "explicit_command_override" ? "override_or_native" : "provider_final" },
|
|
1121
1126
|
tokenUsage:
|
|
1122
1127
|
tokenUsageRecords.length > 0 ? { records: tokenUsageRecords } : {},
|
|
1123
1128
|
logs: [...stdout, ...stderr]
|
|
@@ -1212,25 +1217,6 @@ async function pathExists(value) {
|
|
|
1212
1217
|
}
|
|
1213
1218
|
}
|
|
1214
1219
|
|
|
1215
|
-
function extractAssistantText(raw) {
|
|
1216
|
-
const messages = [];
|
|
1217
|
-
for (const line of String(raw || "").split(/\r?\n/)) {
|
|
1218
|
-
if (!line.trim().startsWith("{")) continue;
|
|
1219
|
-
try {
|
|
1220
|
-
const item = JSON.parse(line);
|
|
1221
|
-
if (item.type === "result" && typeof item.result === "string") messages.push(item.result);
|
|
1222
|
-
if (item.type === "item.completed" && item.item?.type === "agent_message") {
|
|
1223
|
-
if (typeof item.item.text === "string") messages.push(item.item.text);
|
|
1224
|
-
}
|
|
1225
|
-
if (item.type === "assistant" && typeof item.message?.content === "string") {
|
|
1226
|
-
messages.push(item.message.content);
|
|
1227
|
-
}
|
|
1228
|
-
} catch {
|
|
1229
|
-
// Non-JSON executor output is still available to the fallback parser.
|
|
1230
|
-
}
|
|
1231
|
-
}
|
|
1232
|
-
return messages.join("\n");
|
|
1233
|
-
}
|
|
1234
1220
|
|
|
1235
1221
|
function emitOutput(run, text, { backend = run.request.backend, promote = true } = {}) {
|
|
1236
1222
|
for (const line of String(text).split(/\r?\n/)) {
|
|
@@ -3,6 +3,7 @@ import { listRecords, readRecord, updateRecord, TERMINAL, managedBudgetReason }
|
|
|
3
3
|
import { createActionAssociationApi, ActionAssociationApiError } from './action-association-api.js';
|
|
4
4
|
import { actionCandidateRequest, prepareActionAssociation, importActionBinding,
|
|
5
5
|
settleActionAssociation, cancelActionAssociation } from './action-association.js';
|
|
6
|
+
import { CANDIDATE_DISPLAY_MS, needsCandidateSearch, hasFreshCandidates } from './association-refresh.js';
|
|
6
7
|
|
|
7
8
|
const namespaceFor = (origin, scope) => JSON.stringify([new URL(origin).origin, scope.organizationId, scope.userId]);
|
|
8
9
|
const identity = record => ({ provider: record.provider, session: record.session, taskId: record.run.managed.taskId });
|
|
@@ -90,22 +91,34 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
90
91
|
let view = views.get(target.taskId);
|
|
91
92
|
const request = actionCandidateRequest(record);
|
|
92
93
|
const contextRevision = request?.context.revision || null;
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
const active = !TERMINAL.has(record.run.state);
|
|
95
|
+
if (!force && view?.auth === expected && view.active === active && view.contextRevision === contextRevision && now() < view.nextRefresh && (!association?.outbox || view.error)) {
|
|
96
|
+
if (queue && !view.error && hasFreshCandidates(view, now()) && !association?.manualOverride && !association?.job && !association?.outbox && !TERMINAL.has(record.run.state)) await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now(), validate: validateScope(expected) });
|
|
95
97
|
return;
|
|
96
98
|
}
|
|
97
|
-
|
|
99
|
+
const search = request && (needsCandidateSearch(view, expected, contextRevision, active, force) ||
|
|
100
|
+
association?.outbox && !view?.snapshot);
|
|
101
|
+
view = { ...view, auth: expected, active, contextRevision, nextRefresh: now() + refreshMs };
|
|
98
102
|
views.delete(target.taskId); views.set(target.taskId, view);
|
|
99
103
|
while (views.size > 500) views.delete(views.keys().next().value);
|
|
100
104
|
try {
|
|
101
105
|
// Never publish an outbox from an earlier organization or login before a
|
|
102
106
|
// scoped server response confirms who owns the current request.
|
|
103
|
-
const
|
|
104
|
-
const namespace = await importEnvelope(record,
|
|
107
|
+
const envelope = search ? await api.candidates(request, { signal: abort.signal }) : await api.readBinding(target.taskId, { signal: abort.signal });
|
|
108
|
+
const namespace = await importEnvelope(record, envelope, expected);
|
|
109
|
+
const reusable = view.auth === expected && view.namespace === namespace && view.snapshot?.contextRevision === contextRevision;
|
|
110
|
+
const snapshot = search ? envelope : { ...(reusable ? view.snapshot : {}), ...envelope };
|
|
105
111
|
view.namespace = namespace; view.error = null;
|
|
106
|
-
|
|
112
|
+
if (search) {
|
|
113
|
+
view.candidateAuth = expected; view.candidateContextRevision = contextRevision;
|
|
114
|
+
view.candidatesExpireAt = now() + CANDIDATE_DISPLAY_MS;
|
|
115
|
+
} else if (!reusable) {
|
|
116
|
+
view.candidateAuth = null; view.candidateContextRevision = null;
|
|
117
|
+
}
|
|
118
|
+
view.candidates = (search || hasFreshCandidates(view, now()) ? snapshot.candidates || [] : []).map(publicCandidate);
|
|
107
119
|
view.candidateTruncated = snapshot.retrieval?.truncated === true || view.candidates.length > 5;
|
|
108
|
-
view.snapshot =
|
|
120
|
+
view.snapshot = snapshot.candidateRevision ? snapshot : null;
|
|
121
|
+
if (!active && !snapshot.binding?.action && !association?.outbox) view.nextRefresh = Infinity;
|
|
109
122
|
let latest = await fresh(target);
|
|
110
123
|
if (!latest || !await valid(expected)) return;
|
|
111
124
|
const pending = latest.run.managed.actionAssociation?.outbox;
|
|
@@ -123,7 +136,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
123
136
|
await settleActionAssociation({ root, ...target, outboxId: pending.id, namespace, binding: saved.binding, now: now(), validate: validateScope(expected) });
|
|
124
137
|
return;
|
|
125
138
|
}
|
|
126
|
-
if (queue && request && !latest.run.managed.actionAssociation?.manualOverride && !TERMINAL.has(latest.run.state)) {
|
|
139
|
+
if (queue && request && hasFreshCandidates(view, now()) && !latest.run.managed.actionAssociation?.manualOverride && !TERMINAL.has(latest.run.state)) {
|
|
127
140
|
// A read turn is queued only at the original session's safe boundary.
|
|
128
141
|
// Blocked tasks retain candidates for auxiliary correction in the UI.
|
|
129
142
|
await prepareActionAssociation({ root, record: latest, snapshot, webOrigin, now: now(), validate: validateScope(expected) });
|
|
@@ -245,7 +258,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
245
258
|
async prepareCached(record) {
|
|
246
259
|
const expected = await current();
|
|
247
260
|
const view = views.get(record?.run?.managed?.taskId), request = actionCandidateRequest(record);
|
|
248
|
-
if (closed || !auth || !view || view.auth !== auth || view.error || !view
|
|
261
|
+
if (closed || !auth || !view || view.auth !== auth || view.error || !hasFreshCandidates(view, now()) || !request || view.contextRevision !== request.context.revision || view.snapshot.contextRevision !== request.context.revision || view.namespace !== namespaceFor(webOrigin, view.snapshot.scope)) return record;
|
|
249
262
|
try {
|
|
250
263
|
const prepared = await prepareActionAssociation({ root, record, snapshot: view.snapshot, webOrigin, now: now(), validate: validateScope(expected) });
|
|
251
264
|
return prepared || record;
|
|
@@ -268,7 +281,7 @@ export function createActionAssociationHost({ root, sessionRoot, webOrigin, api
|
|
|
268
281
|
!view || view.auth !== expected ? { status: 'loading', binding: null, candidates: [], canCorrect: false } : {
|
|
269
282
|
status: view.error || local?.status || 'waiting_boundary',
|
|
270
283
|
binding: view.namespace && local?.namespace === view.namespace ? publicBinding(local.binding) : null,
|
|
271
|
-
candidates: (view.candidates || []).slice(0,5), canCorrect: !view.error,
|
|
284
|
+
candidates: (hasFreshCandidates(view, now()) ? view.candidates || [] : []).slice(0,5), canCorrect: !view.error,
|
|
272
285
|
contextRevision: view.contextRevision, candidateRevision: view.snapshot?.candidateRevision || null, retryOn: local?.retryOn || null, truncated: view.candidateTruncated || false,
|
|
273
286
|
};
|
|
274
287
|
let bytes = Buffer.byteLength(JSON.stringify(run.actionAssociation));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Candidate search is event-driven; binding reads remain independently renewable.
|
|
2
|
+
// Retain only the search identity after expiry, so a timer cannot restart search
|
|
3
|
+
// or keep displaying candidate text after its authorization window has elapsed.
|
|
4
|
+
export const CANDIDATE_DISPLAY_MS = 60_000;
|
|
5
|
+
|
|
6
|
+
export function needsCandidateSearch(view, auth, contextRevision, active, force = false) {
|
|
7
|
+
return force || active && (view?.candidateAuth !== auth || view.candidateContextRevision !== contextRevision);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function hasFreshCandidates(view, now) {
|
|
11
|
+
return Boolean(view?.snapshot?.candidateRevision && now < view.candidatesExpireAt);
|
|
12
|
+
}
|
|
@@ -3,9 +3,14 @@ import {
|
|
|
3
3
|
listRecords,
|
|
4
4
|
readRecord,
|
|
5
5
|
updateRecord,
|
|
6
|
+
TERMINAL,
|
|
6
7
|
} from '../codex-plugins/simy/hooks/delivery-loop.mjs';
|
|
7
8
|
import { associationRevision } from './action-association.js';
|
|
8
9
|
import { ActionAssociationApiError } from './action-association-api.js';
|
|
10
|
+
import { CANDIDATE_DISPLAY_MS, needsCandidateSearch, hasFreshCandidates } from './association-refresh.js';
|
|
11
|
+
|
|
12
|
+
const activeConversation = (candidate, record) => !TERMINAL.has(record.run?.state) &&
|
|
13
|
+
(candidate.isRunning !== false || candidate.autoContinue === true || candidate.analysis?.state === 'continue');
|
|
9
14
|
|
|
10
15
|
export const sessionAssociationContext = (candidate) => {
|
|
11
16
|
const grounded =
|
|
@@ -111,6 +116,7 @@ export function createOrdinaryAssociationStore({
|
|
|
111
116
|
if (record.run?.managed) return;
|
|
112
117
|
const auth = await api.authRevision(),
|
|
113
118
|
context = sessionAssociationContext(candidate);
|
|
119
|
+
const active = activeConversation(candidate, record);
|
|
114
120
|
let view = views.get(taskId);
|
|
115
121
|
if (!auth) {
|
|
116
122
|
rememberView(taskId, { auth, status: 'signed_out' });
|
|
@@ -120,13 +126,15 @@ export function createOrdinaryAssociationStore({
|
|
|
120
126
|
!force &&
|
|
121
127
|
view?.auth === auth &&
|
|
122
128
|
view.contextRevision === context.revision &&
|
|
129
|
+
view.active === active &&
|
|
123
130
|
now() < view.nextRefresh
|
|
124
131
|
)
|
|
125
132
|
return;
|
|
126
133
|
const previous = view?.auth === auth ? view : null;
|
|
127
134
|
const sameContext = previous?.contextRevision === context.revision;
|
|
135
|
+
const search = needsCandidateSearch(previous, auth, context.revision, active, force);
|
|
128
136
|
view = {
|
|
129
|
-
...previous, auth, contextRevision: context.revision,
|
|
137
|
+
...previous, auth, active, contextRevision: context.revision,
|
|
130
138
|
// Background renewal retains the last stable presentation. A user refresh
|
|
131
139
|
// can show progress without flashing every cached result back to loading.
|
|
132
140
|
status: force ? 'loading' : previous?.status || 'loading',
|
|
@@ -134,19 +142,22 @@ export function createOrdinaryAssociationStore({
|
|
|
134
142
|
};
|
|
135
143
|
rememberView(taskId, view);
|
|
136
144
|
try {
|
|
137
|
-
const
|
|
145
|
+
const envelope = search ? await api.candidates(
|
|
138
146
|
{ taskId, context, limit: 10 },
|
|
139
147
|
{ signal: abort.signal },
|
|
140
|
-
);
|
|
148
|
+
) : await api.readBinding(taskId, { signal: abort.signal });
|
|
141
149
|
if (closed || (await api.authRevision()) !== auth)
|
|
142
150
|
throw new ActionAssociationApiError('account_changed');
|
|
143
151
|
if (
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
(snapshot.binding && snapshot.binding.taskId !== taskId)
|
|
152
|
+
search && (envelope.taskId !== taskId || envelope.contextRevision !== context.revision) ||
|
|
153
|
+
(envelope.binding && envelope.binding.taskId !== taskId)
|
|
147
154
|
)
|
|
148
155
|
throw new ActionAssociationApiError('invalid_response');
|
|
149
|
-
const namespace = namespaceFor(webOrigin,
|
|
156
|
+
const namespace = namespaceFor(webOrigin, envelope.scope);
|
|
157
|
+
const reusable = sameContext && previous.namespace === namespace && hasFreshCandidates(previous, now());
|
|
158
|
+
const snapshot = search ? envelope : {
|
|
159
|
+
...(reusable ? previous.snapshot : {}), ...envelope, taskId, contextRevision: context.revision,
|
|
160
|
+
};
|
|
150
161
|
const persisted = await updateRecord(
|
|
151
162
|
root,
|
|
152
163
|
candidate.provider,
|
|
@@ -184,7 +195,10 @@ export function createOrdinaryAssociationStore({
|
|
|
184
195
|
Object.assign(view, {
|
|
185
196
|
namespace,
|
|
186
197
|
snapshot,
|
|
187
|
-
|
|
198
|
+
candidateAuth: search ? auth : previous?.namespace === namespace ? previous?.candidateAuth : null,
|
|
199
|
+
candidateContextRevision: search ? context.revision : previous?.namespace === namespace ? previous?.candidateContextRevision : null,
|
|
200
|
+
candidatesExpireAt: search ? now() + CANDIDATE_DISPLAY_MS : previous?.candidatesExpireAt,
|
|
201
|
+
nextRefresh: snapshot.binding?.action ? now() + 15_000 : active ? now() + 60_000 : Infinity,
|
|
188
202
|
failures: 0,
|
|
189
203
|
status: snapshot.binding?.action
|
|
190
204
|
? 'bound'
|
|
@@ -230,7 +244,8 @@ export function createOrdinaryAssociationStore({
|
|
|
230
244
|
candidate.binding = { ...candidate.binding, taskId };
|
|
231
245
|
if (record.run?.managed) continue;
|
|
232
246
|
const cached = views.get(taskId),
|
|
233
|
-
context = sessionAssociationContext(candidate)
|
|
247
|
+
context = sessionAssociationContext(candidate),
|
|
248
|
+
active = activeConversation(candidate, record);
|
|
234
249
|
if (
|
|
235
250
|
auth &&
|
|
236
251
|
!closed &&
|
|
@@ -238,6 +253,7 @@ export function createOrdinaryAssociationStore({
|
|
|
238
253
|
(!cached ||
|
|
239
254
|
cached.auth !== auth ||
|
|
240
255
|
cached.contextRevision !== context.revision ||
|
|
256
|
+
cached.active !== active ||
|
|
241
257
|
now() >= cached.nextRefresh)
|
|
242
258
|
) {
|
|
243
259
|
const scopedBinding = cached?.auth === auth && cached.snapshot && record.actionAssociation?.authRevision === auth && record.actionAssociation.namespace === cached.namespace;
|
|
@@ -247,7 +263,7 @@ export function createOrdinaryAssociationStore({
|
|
|
247
263
|
}
|
|
248
264
|
const view = views.get(taskId);
|
|
249
265
|
const bindingSnapshot = view?.auth === auth && auth ? view.snapshot : null;
|
|
250
|
-
const snapshot = bindingSnapshot?.contextRevision === context.revision ? bindingSnapshot : null;
|
|
266
|
+
const snapshot = bindingSnapshot?.contextRevision === context.revision && hasFreshCandidates(view, now()) ? bindingSnapshot : null;
|
|
251
267
|
const local = record.actionAssociation;
|
|
252
268
|
const scoped = Boolean(bindingSnapshot && local?.authRevision === auth && local.namespace === view.namespace);
|
|
253
269
|
const newer = scoped &&
|