@oar-sh/oar 0.9.1 → 0.9.3
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/.github/extensions/web-relay/extension.mjs +0 -3
- package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
- package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
- package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
- package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
- package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
- package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +142 -11
- package/package.json +8 -4
- package/server/README.md +59 -22
- package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
- package/server/claude-worker/claude-session-process.mjs +1013 -45
- package/server/claude-worker/claude-session-worker.mjs +54 -9
- package/server/claude-worker/claude-turn-publisher.mjs +110 -19
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-sdk-runtime.mjs +12 -3
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-attachments.mjs +35 -2
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-model-switch.mjs +425 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
- package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
- package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
- package/server/cursor-worker/cursor-session-worker.mjs +5 -5
- package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
- package/server/db-schema.mjs +61 -1
- package/server/features.mjs +155 -41
- package/server/grok-worker/grok-session-worker.mjs +5 -5
- package/server/grok-worker/grok-turn-runner.mjs +45 -4
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/annotate-core.mjs +181 -0
- package/server/public/app/annotate-editor.js +617 -0
- package/server/public/app/api-client.js +86 -5
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +172 -3
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +34 -19
- package/server/public/app/bootstrap.js +153 -69
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/context-tier-options.mjs +24 -4
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-preferences.mjs +47 -10
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +1032 -218
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +190 -24
- package/server/public/app/model-selector-options.mjs +185 -1
- package/server/public/app/new-conversation-model-choice.mjs +29 -9
- package/server/public/app/pwa-install.js +64 -136
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/reasoning-effort-labels.mjs +13 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
- package/server/public/app/router.js +120 -11
- package/server/public/app/settings-modal.js +225 -20
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +47 -4
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +159 -16
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/app/update-ui.js +194 -0
- package/server/public/index.html +483 -24
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/relay.mjs +23 -21
- package/server/repositories/message-repository.mjs +78 -21
- package/server/repositories/question-repository.mjs +19 -1
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/ask-user-routes.mjs +10 -0
- package/server/routes/messages-routes.mjs +689 -159
- package/server/routes/sessions-routes.mjs +574 -80
- package/server/server-runtime.mjs +393 -925
- package/server/services/claude-auth-service.mjs +7 -1
- package/server/services/cli-install-service.mjs +8 -27
- package/server/services/cli-process-runner.mjs +30 -0
- package/server/services/copilot-model-discovery-service.mjs +202 -0
- package/server/services/data-dir-resolution.mjs +47 -0
- package/server/services/grok-auth-service.mjs +9 -1
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/model-variant-catalog-service.mjs +1183 -0
- package/server/services/oar-cli-helpers.mjs +6 -2
- package/server/services/relay-prompt-sanitizer.mjs +30 -3
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +144 -16
- package/server/services/sdk-session-sync-service.mjs +84 -27
- package/server/services/session-worker-registry-service.mjs +116 -3
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +119 -24
- package/server/services/status-event-service.mjs +8 -2
- package/server/services/steer-resend.mjs +45 -0
- package/server/services/update-check-service.mjs +220 -0
- package/server/services/update-install-service.mjs +279 -0
- package/server/services/windows-autostart-service.mjs +42 -6
- package/server/services/windows-boot-autostart-service.mjs +487 -0
- package/shared/ask-user-bridge.mjs +37 -5
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/claude-model-catalog.mjs +49 -0
- package/shared/context-window-fallbacks.mjs +1 -0
- package/shared/control-poller.mjs +4 -1
- package/shared/copilot-model-order.mjs +182 -0
- package/shared/media-embed-instructions.mjs +35 -0
- package/shared/model-descriptors.mjs +181 -30
- package/shared/model-id.mjs +6 -0
- package/shared/pwa-app-name.mjs +44 -0
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/update-semver.mjs +50 -0
- package/shared/worker-crash-guard.mjs +40 -14
- package/shared/worker-runtime/api-client.mjs +34 -0
- package/shared/worker-runtime/config-loader.mjs +104 -0
- package/shared/worker-runtime/heartbeat.mjs +109 -0
- package/shared/worker-runtime/prompt-context.mjs +160 -0
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
- package/server/claude-worker/claude-session-test-harness.mjs +0 -236
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.sh +0 -62
- package/server/services/fixtures/cli-install-stub.sh +0 -75
- package/server/services/fixtures/grok-stub.sh +0 -86
package/server/features.mjs
CHANGED
|
@@ -2,25 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
5
|
+
|
|
6
|
+
// The single registry of relay feature flags. Server resolution, the settings
|
|
7
|
+
// API payload, and the web UI's Features tab all render from these entries, so
|
|
8
|
+
// a new in-development feature only needs a row here plus the code it gates.
|
|
9
|
+
// `noop` marks a flag that is wired into resolution but not yet read by any
|
|
10
|
+
// behavior — shown honestly in the UI so a rollout can be staged later.
|
|
11
|
+
export const FEATURE_REGISTRY = Object.freeze([
|
|
12
|
+
Object.freeze({
|
|
13
|
+
name: 'SESSION_WORKER_ROUTING_ENABLED',
|
|
14
|
+
label: 'Session worker routing',
|
|
15
|
+
default: true,
|
|
16
|
+
noop: false,
|
|
17
|
+
description: 'Give each conversation its own dedicated worker process. The relay routes every message to the worker that owns that conversation, monitors its health, and recovers it if it crashes — a stuck or crashed turn in one conversation no longer stalls the others. When off, all conversations share the legacy polling pipeline.',
|
|
18
|
+
}),
|
|
19
|
+
Object.freeze({
|
|
20
|
+
name: 'SESSION_WORKER_CONTINUATION_ROUTING_ENABLED',
|
|
21
|
+
label: 'Continuation answer routing',
|
|
22
|
+
default: true,
|
|
23
|
+
noop: false,
|
|
24
|
+
description: 'Validate ownership strictly when you answer a clarification question: the relay checks the question’s conversation, continuation id, and owning worker before accepting the answer, so a stale or cross-wired reply is rejected instead of landing in the wrong session. Most useful together with session worker routing.',
|
|
25
|
+
}),
|
|
26
|
+
Object.freeze({
|
|
27
|
+
name: 'SESSION_WORKER_FALLBACK_RESTART_ENABLED',
|
|
28
|
+
label: 'Worker fallback restart (reserved)',
|
|
29
|
+
default: false,
|
|
30
|
+
noop: true,
|
|
31
|
+
description: 'Reserved rollout gate with no effect yet. It is intended to let the relay restart a turn on the fallback pipeline when a session worker cannot be revived. The toggle is shown so the rollout can be staged later without a release; enabling it today changes nothing.',
|
|
32
|
+
}),
|
|
33
|
+
Object.freeze({
|
|
34
|
+
name: 'IMAGE_CONVERSATION_CONTINUITY_ENABLED',
|
|
35
|
+
label: 'Generated-image continuity',
|
|
36
|
+
default: true,
|
|
37
|
+
noop: false,
|
|
38
|
+
description: 'Track the lineage of generated images so a follow-up like “edit this image” continues from the exact image you mean, not just the most recent one. The relay records which operation produced each image and threads edits back through the right source. When off, image follow-ups lose that source linkage.',
|
|
39
|
+
}),
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const DEFAULT_FEATURES = Object.freeze(Object.fromEntries(
|
|
43
|
+
FEATURE_REGISTRY.map((entry) => [entry.name, entry.default]),
|
|
44
|
+
));
|
|
21
45
|
|
|
22
46
|
const ENV_PREFIX = 'COPILOT_REMOTE_';
|
|
23
|
-
const FEATURE_NAMES = Object.freeze(
|
|
47
|
+
const FEATURE_NAMES = Object.freeze(FEATURE_REGISTRY.map((entry) => entry.name));
|
|
48
|
+
|
|
49
|
+
export function featureSettingKey(name) {
|
|
50
|
+
return `feature_${String(name || '').trim().toLowerCase()}`;
|
|
51
|
+
}
|
|
24
52
|
|
|
25
53
|
function parseBoolean(value) {
|
|
26
54
|
if (typeof value === 'boolean') return value;
|
|
@@ -42,20 +70,6 @@ function normalizeKnownFeatureFlags(input) {
|
|
|
42
70
|
return normalized;
|
|
43
71
|
}
|
|
44
72
|
|
|
45
|
-
function readFeaturesFromConfig() {
|
|
46
|
-
if (!fs.existsSync(CONFIG_PATH)) {
|
|
47
|
-
return {};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
try {
|
|
51
|
-
const parsed = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
52
|
-
const configured = parsed && typeof parsed === 'object' ? parsed.features : null;
|
|
53
|
-
return normalizeKnownFeatureFlags(configured);
|
|
54
|
-
} catch {
|
|
55
|
-
return {};
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
73
|
function readFeaturesFromEnv(env = process.env) {
|
|
60
74
|
const entries = [];
|
|
61
75
|
for (const key of FEATURE_NAMES) {
|
|
@@ -68,6 +82,17 @@ function readFeaturesFromEnv(env = process.env) {
|
|
|
68
82
|
return Object.fromEntries(entries);
|
|
69
83
|
}
|
|
70
84
|
|
|
85
|
+
function readFeaturesFromSettings(readSetting) {
|
|
86
|
+
if (typeof readSetting !== 'function') return {};
|
|
87
|
+
const entries = [];
|
|
88
|
+
for (const key of FEATURE_NAMES) {
|
|
89
|
+
const parsed = parseBoolean(readSetting(featureSettingKey(key)));
|
|
90
|
+
if (parsed === null) continue;
|
|
91
|
+
entries.push([key, parsed]);
|
|
92
|
+
}
|
|
93
|
+
return Object.fromEntries(entries);
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
export function normalizeFeatureFlags(featureFlags = null) {
|
|
72
97
|
return Object.freeze({
|
|
73
98
|
...DEFAULT_FEATURES,
|
|
@@ -75,21 +100,110 @@ export function normalizeFeatureFlags(featureFlags = null) {
|
|
|
75
100
|
});
|
|
76
101
|
}
|
|
77
102
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
103
|
+
/**
|
|
104
|
+
* The boot-time flag snapshot: registry defaults, overridden by rows in
|
|
105
|
+
* app_settings, overridden by COPILOT_REMOTE_* environment variables. Env stays
|
|
106
|
+
* on top so an isolated test server can pin a flag regardless of what its
|
|
107
|
+
* database says.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveBootFeatureFlags({ readSetting, env = process.env } = {}) {
|
|
82
110
|
return normalizeFeatureFlags({
|
|
83
|
-
...
|
|
111
|
+
...readFeaturesFromSettings(readSetting),
|
|
84
112
|
...readFeaturesFromEnv(env),
|
|
85
113
|
});
|
|
86
114
|
}
|
|
87
115
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Per-flag rows for the settings API: what is active in this process, what is
|
|
118
|
+
* stored, whether the environment pins it, and what would be active after a
|
|
119
|
+
* restart. `restartRequired` is the only signal the UI needs to show the
|
|
120
|
+
* restart notice — flags are resolved once at boot and never reloaded.
|
|
121
|
+
*/
|
|
122
|
+
export function computeFeatureFlagState({ readSetting, env = process.env, activeFlags = null } = {}) {
|
|
123
|
+
const active = normalizeFeatureFlags(activeFlags);
|
|
124
|
+
const envFlags = readFeaturesFromEnv(env);
|
|
125
|
+
const storedFlags = readFeaturesFromSettings(readSetting);
|
|
126
|
+
return FEATURE_REGISTRY.map((entry) => {
|
|
127
|
+
const stored = Object.hasOwn(storedFlags, entry.name) ? storedFlags[entry.name] : null;
|
|
128
|
+
const envOverride = Object.hasOwn(envFlags, entry.name) ? envFlags[entry.name] : null;
|
|
129
|
+
const effectiveNext = envOverride ?? stored ?? entry.default;
|
|
130
|
+
return {
|
|
131
|
+
name: entry.name,
|
|
132
|
+
label: entry.label,
|
|
133
|
+
description: entry.description,
|
|
134
|
+
default: entry.default,
|
|
135
|
+
noop: entry.noop,
|
|
136
|
+
active: active[entry.name],
|
|
137
|
+
stored,
|
|
138
|
+
envOverride,
|
|
139
|
+
effectiveNext,
|
|
140
|
+
restartRequired: effectiveNext !== active[entry.name],
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One-time boot migration away from a `features` key in config.json: known
|
|
147
|
+
* flags move into app_settings (without clobbering rows that already exist)
|
|
148
|
+
* and the key is stripped from the file.
|
|
149
|
+
*
|
|
150
|
+
* A read that fails is never followed by a write — config.json holds the auth
|
|
151
|
+
* token, so rewriting an unparseable-but-recoverable file would be far worse
|
|
152
|
+
* than running one boot on defaults (see writeCliBinariesToConfigFile in
|
|
153
|
+
* cli-install-service.mjs for the full rationale). The rewrite itself is
|
|
154
|
+
* tmp-file-then-rename in the config directory, mode 0600, for the same
|
|
155
|
+
* partial-write and token-exposure reasons.
|
|
156
|
+
*/
|
|
157
|
+
export function migrateLegacyConfigFeatures({
|
|
158
|
+
configPath,
|
|
159
|
+
readSetting,
|
|
160
|
+
writeSetting,
|
|
161
|
+
fsImpl = fs,
|
|
162
|
+
log = (message) => console.warn(message),
|
|
163
|
+
} = {}) {
|
|
164
|
+
const target = String(configPath || '').trim();
|
|
165
|
+
if (!target || typeof readSetting !== 'function' || typeof writeSetting !== 'function') {
|
|
166
|
+
return { migrated: false };
|
|
167
|
+
}
|
|
168
|
+
if (!fsImpl.existsSync(target)) return { migrated: false };
|
|
169
|
+
|
|
170
|
+
let parsed = null;
|
|
171
|
+
try {
|
|
172
|
+
parsed = JSON.parse(fsImpl.readFileSync(target, 'utf8'));
|
|
173
|
+
} catch (error) {
|
|
174
|
+
log(`feature flags: refusing to migrate ${target}: it could not be parsed (${error?.message || error}); flags fall back to defaults + app settings + environment for this boot`);
|
|
175
|
+
return { migrated: false };
|
|
176
|
+
}
|
|
177
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !('features' in parsed)) {
|
|
178
|
+
return { migrated: false };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const legacy = normalizeKnownFeatureFlags(parsed.features);
|
|
182
|
+
const adopted = [];
|
|
183
|
+
for (const [name, enabled] of Object.entries(legacy)) {
|
|
184
|
+
if (parseBoolean(readSetting(featureSettingKey(name))) !== null) continue;
|
|
185
|
+
writeSetting(featureSettingKey(name), enabled ? '1' : '0');
|
|
186
|
+
adopted.push(name);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
delete parsed.features;
|
|
190
|
+
const tmpPath = path.join(
|
|
191
|
+
path.dirname(target),
|
|
192
|
+
`.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`,
|
|
193
|
+
);
|
|
194
|
+
try {
|
|
195
|
+
fsImpl.writeFileSync(tmpPath, JSON.stringify(parsed, null, 2), { mode: 0o600 });
|
|
196
|
+
try { fsImpl.chmodSync(tmpPath, 0o600); } catch {}
|
|
197
|
+
fsImpl.renameSync(tmpPath, target);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
try { fsImpl.unlinkSync(tmpPath); } catch {}
|
|
200
|
+
log(`feature flags: migrated values to app settings but could not strip the features key from ${target} (${error?.message || error})`);
|
|
201
|
+
return { migrated: true, adopted, stripped: false };
|
|
202
|
+
}
|
|
203
|
+
return { migrated: true, adopted, stripped: true };
|
|
204
|
+
}
|
|
91
205
|
|
|
92
|
-
export function getSessionWorkerFeatureFlags(featureFlags
|
|
206
|
+
export function getSessionWorkerFeatureFlags(featureFlags) {
|
|
93
207
|
const normalized = normalizeFeatureFlags(featureFlags);
|
|
94
208
|
return {
|
|
95
209
|
enabled: normalized.SESSION_WORKER_ROUTING_ENABLED === true,
|
|
@@ -98,7 +212,7 @@ export function getSessionWorkerFeatureFlags(featureFlags = FEATURES) {
|
|
|
98
212
|
};
|
|
99
213
|
}
|
|
100
214
|
|
|
101
|
-
export function isFeatureEnabled(featureName, featureFlags
|
|
215
|
+
export function isFeatureEnabled(featureName, featureFlags) {
|
|
102
216
|
const name = String(featureName || '').trim();
|
|
103
217
|
if (!name) return false;
|
|
104
218
|
const normalized = normalizeFeatureFlags(featureFlags);
|
|
@@ -8,10 +8,10 @@ import { fileURLToPath } from 'url';
|
|
|
8
8
|
import {
|
|
9
9
|
loadTokenFromConfig,
|
|
10
10
|
resolveRelayServerUrl,
|
|
11
|
-
} from '
|
|
12
|
-
import { createApiClient } from '
|
|
13
|
-
import { createWorkerWebSocketLink } from '
|
|
14
|
-
import { createHeartbeatController } from '
|
|
11
|
+
} from '../../shared/worker-runtime/config-loader.mjs';
|
|
12
|
+
import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
|
|
13
|
+
import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
|
|
14
|
+
import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
|
|
15
15
|
import { createControlPoller } from '../../shared/control-poller.mjs';
|
|
16
16
|
import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
|
|
17
17
|
import { createGrokTurnRunner } from './grok-turn-runner.mjs';
|
|
@@ -131,7 +131,7 @@ async function main() {
|
|
|
131
131
|
installWorkerCrashGuard({
|
|
132
132
|
api,
|
|
133
133
|
workerName: 'grok-session-worker',
|
|
134
|
-
getActiveQueueMessageIds: () => [turnRunner.
|
|
134
|
+
getActiveQueueMessageIds: () => [turnRunner.getActiveQueueAttempt()],
|
|
135
135
|
});
|
|
136
136
|
|
|
137
137
|
dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
import { buildGrokContextUsage, resolveGrokContextWindow } from './grok-context-usage.mjs';
|
|
7
7
|
import { extractGrokUsageFromPromptResult, normalizeGrokTurnUsage } from '../services/plan-usage-grok.mjs';
|
|
8
8
|
import { createPreviewInstructionsProvider } from '../../shared/preview-instructions.mjs';
|
|
9
|
+
import { renderMediaEmbedInstructionBlock } from '../../shared/media-embed-instructions.mjs';
|
|
9
10
|
import { countPlanLikeLines } from '../../shared/plan-lines.mjs';
|
|
10
11
|
import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
|
|
11
12
|
|
|
@@ -43,6 +44,17 @@ export function buildGrokPlanReadyBoardPayload({
|
|
|
43
44
|
};
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
// Delivered rows carry an attemptId the worker echoes on every write so the
|
|
48
|
+
// server can fence out a superseded attempt (HTTP 409 stale_attempt) instead
|
|
49
|
+
// of letting two attempts interleave writes on the same row. The real api
|
|
50
|
+
// client reports the rejection body as a string; tolerate a parsed object too.
|
|
51
|
+
function isStaleAttemptRejection(error) {
|
|
52
|
+
if (error?.status !== 409) return false;
|
|
53
|
+
const detail = error?.detail;
|
|
54
|
+
const text = typeof detail === 'string' ? detail : String(detail?.error || '');
|
|
55
|
+
return text.includes('stale_attempt');
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
function buildGrokTerminalError(classified, message) {
|
|
47
59
|
return {
|
|
48
60
|
kind: 'grok-turn-failed',
|
|
@@ -110,6 +122,7 @@ export function createGrokTurnRunner({
|
|
|
110
122
|
// prefix like the mode nudge — once per worker, since the Grok session keeps
|
|
111
123
|
// the history it was told in.
|
|
112
124
|
let previewInstructionsSent = false;
|
|
125
|
+
let mediaInstructionsSent = false;
|
|
113
126
|
// The user's max-turn-duration ceiling (0 = no limit), piggybacked on queue
|
|
114
127
|
// deliveries; null means no delivery has told us yet and the ACP defaults
|
|
115
128
|
// apply.
|
|
@@ -119,6 +132,13 @@ export function createGrokTurnRunner({
|
|
|
119
132
|
return waitingForTurn ? String(activeMessage?.id || '') : '';
|
|
120
133
|
}
|
|
121
134
|
|
|
135
|
+
// Crash-guard shape: the attempt id, when known, fences the dying worker's
|
|
136
|
+
// requeue so it cannot yank back a row a successor attempt now owns.
|
|
137
|
+
function getActiveQueueAttempt() {
|
|
138
|
+
if (!waitingForTurn || !activeMessage?.id) return null;
|
|
139
|
+
return { id: String(activeMessage.id), attemptId: activeMessage.attemptId || null };
|
|
140
|
+
}
|
|
141
|
+
|
|
122
142
|
function isTurnActive() {
|
|
123
143
|
return waitingForTurn;
|
|
124
144
|
}
|
|
@@ -176,6 +196,7 @@ export function createGrokTurnRunner({
|
|
|
176
196
|
mode: message.relayMode || 'agent',
|
|
177
197
|
text,
|
|
178
198
|
...(subagentRunId ? { subagentRunId } : {}),
|
|
199
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
179
200
|
}).catch(() => {});
|
|
180
201
|
}
|
|
181
202
|
|
|
@@ -195,6 +216,7 @@ export function createGrokTurnRunner({
|
|
|
195
216
|
text: payload.text,
|
|
196
217
|
done: payload.done === true,
|
|
197
218
|
...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
|
|
219
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
198
220
|
}).catch(() => {});
|
|
199
221
|
return;
|
|
200
222
|
}
|
|
@@ -207,6 +229,7 @@ export function createGrokTurnRunner({
|
|
|
207
229
|
text: payload.text,
|
|
208
230
|
done: payload.done === true,
|
|
209
231
|
...(payload.subagentRunId ? { subagentRunId: payload.subagentRunId } : {}),
|
|
232
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
210
233
|
}).catch(() => {});
|
|
211
234
|
return;
|
|
212
235
|
}
|
|
@@ -222,6 +245,7 @@ export function createGrokTurnRunner({
|
|
|
222
245
|
parentSubagentId: payload.parentSubagentId || undefined,
|
|
223
246
|
displayName: payload.displayName || undefined,
|
|
224
247
|
status: payload.status,
|
|
248
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
225
249
|
}).catch(() => {});
|
|
226
250
|
return;
|
|
227
251
|
}
|
|
@@ -289,6 +313,7 @@ export function createGrokTurnRunner({
|
|
|
289
313
|
mode: message.relayMode || 'agent',
|
|
290
314
|
text: String(text || ''),
|
|
291
315
|
done: true,
|
|
316
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
292
317
|
}).catch(() => {});
|
|
293
318
|
}
|
|
294
319
|
|
|
@@ -301,8 +326,18 @@ export function createGrokTurnRunner({
|
|
|
301
326
|
modelOrigin: modelOrigin
|
|
302
327
|
|| (String(message?.model || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'),
|
|
303
328
|
...(terminalError ? { terminalError } : {}),
|
|
304
|
-
|
|
305
|
-
|
|
329
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
330
|
+
}).catch(async (error) => {
|
|
331
|
+
// A stale_attempt rejection means the server already handed this row to
|
|
332
|
+
// a newer attempt; requeueing would yank it back, so drop instead.
|
|
333
|
+
if (isStaleAttemptRejection(error)) {
|
|
334
|
+
dbg('response rejected as stale_attempt; dropping superseded turn', message.id);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
await api('POST', '/api/requeue', {
|
|
338
|
+
messageId: message.id,
|
|
339
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
340
|
+
}).catch(() => {});
|
|
306
341
|
});
|
|
307
342
|
}
|
|
308
343
|
|
|
@@ -339,7 +374,8 @@ export function createGrokTurnRunner({
|
|
|
339
374
|
: await Promise.resolve()
|
|
340
375
|
.then(() => getPreviewInstructions?.())
|
|
341
376
|
.catch(() => '');
|
|
342
|
-
const
|
|
377
|
+
const mediaBlock = mediaInstructionsSent ? '' : renderMediaEmbedInstructionBlock();
|
|
378
|
+
const promptText = [previewBlock, mediaBlock, modeNudge, userText].filter(Boolean).join('\n\n');
|
|
343
379
|
const pendingNudgedRelayMode = String(message.relayMode || 'agent').trim().toLowerCase();
|
|
344
380
|
|
|
345
381
|
let turn = null;
|
|
@@ -378,6 +414,7 @@ export function createGrokTurnRunner({
|
|
|
378
414
|
}
|
|
379
415
|
lastNudgedRelayMode = pendingNudgedRelayMode;
|
|
380
416
|
if (previewBlock) previewInstructionsSent = true;
|
|
417
|
+
if (mediaBlock) mediaInstructionsSent = true;
|
|
381
418
|
break;
|
|
382
419
|
} catch (error) {
|
|
383
420
|
// A stale agent whose previous prompt never settled reports busy;
|
|
@@ -433,7 +470,10 @@ export function createGrokTurnRunner({
|
|
|
433
470
|
await publishFinalStream(message, fallbackText);
|
|
434
471
|
await publishResponse(message, { text: fallbackText, model: responseModel });
|
|
435
472
|
} else {
|
|
436
|
-
await api('POST', '/api/requeue', {
|
|
473
|
+
await api('POST', '/api/requeue', {
|
|
474
|
+
messageId: message.id,
|
|
475
|
+
...(message.attemptId ? { attemptId: message.attemptId } : {}),
|
|
476
|
+
}).catch(() => {});
|
|
437
477
|
}
|
|
438
478
|
return true;
|
|
439
479
|
}
|
|
@@ -513,6 +553,7 @@ export function createGrokTurnRunner({
|
|
|
513
553
|
return {
|
|
514
554
|
handlePendingPayload,
|
|
515
555
|
getActiveQueueMessageId,
|
|
556
|
+
getActiveQueueAttempt,
|
|
516
557
|
isTurnActive,
|
|
517
558
|
dispose,
|
|
518
559
|
setTurnCeilingMs(value) {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration: give steering settle stubs a durable shape the transcript can
|
|
5
|
+
* render on reload.
|
|
6
|
+
*
|
|
7
|
+
* 1. `messages.source_message_id` — the user message an assistant row answers.
|
|
8
|
+
* The link used to live only in `queue.response_message_id`, and the queue
|
|
9
|
+
* keeps just the newest 200 finished rows, so older assistant rows lost
|
|
10
|
+
* their anchor and fell back to response-time ordering: an absorbed
|
|
11
|
+
* (handed-off) reply then sorted below the steered message it hands off to,
|
|
12
|
+
* and the merge never applied. Backfilled from whatever queue rows remain.
|
|
13
|
+
* 2. `messages.resend_of_message_id` — on a user message sent with Resend,
|
|
14
|
+
* the stopped steer it re-sends. Durable, so "Resent" survives reloads and
|
|
15
|
+
* other devices, and the relay can refuse a second Resend of one original.
|
|
16
|
+
* 3. Fold stubs saved as kind='absorbed' become kind='folded'. 'absorbed'
|
|
17
|
+
* tells the client the reply continues through the NEXT user message, so
|
|
18
|
+
* the next ordinary message after a fold was styled as steered.
|
|
19
|
+
*
|
|
20
|
+
* Idempotent: the column is added once, the backfill only fills NULLs, and the
|
|
21
|
+
* kind rewrite matches nothing once applied. Safe to run on every boot.
|
|
22
|
+
*
|
|
23
|
+
* Usage: node server/migrations/0004-steer-settle-markers.mjs [path/to/copilot.db]
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { fileURLToPath } from 'url';
|
|
27
|
+
import path from 'path';
|
|
28
|
+
import process from 'process';
|
|
29
|
+
import Database from 'better-sqlite3';
|
|
30
|
+
|
|
31
|
+
import { STEER_FOLDED_TEXT } from '../../shared/steer-settle-markers.mjs';
|
|
32
|
+
|
|
33
|
+
function tableExists(db, name) {
|
|
34
|
+
return !!db
|
|
35
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
|
|
36
|
+
.get(name);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function columnNames(db, table) {
|
|
40
|
+
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => String(column.name)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function migrateSteerSettleMarkers(db) {
|
|
44
|
+
if (!tableExists(db, 'messages')) {
|
|
45
|
+
return { applied: false, reason: 'messages table not found', sourceLinksBackfilled: 0, foldStubsRekinded: 0 };
|
|
46
|
+
}
|
|
47
|
+
let columns = columnNames(db, 'messages');
|
|
48
|
+
let columnAdded = false;
|
|
49
|
+
if (!columns.has('source_message_id')) {
|
|
50
|
+
db.exec(`ALTER TABLE messages ADD COLUMN source_message_id TEXT`);
|
|
51
|
+
columnAdded = true;
|
|
52
|
+
}
|
|
53
|
+
if (!columns.has('resend_of_message_id')) {
|
|
54
|
+
db.exec(`ALTER TABLE messages ADD COLUMN resend_of_message_id TEXT`);
|
|
55
|
+
columnAdded = true;
|
|
56
|
+
}
|
|
57
|
+
if (columnAdded) columns = columnNames(db, 'messages');
|
|
58
|
+
let sourceLinksBackfilled = 0;
|
|
59
|
+
let foldStubsRekinded = 0;
|
|
60
|
+
db.transaction(() => {
|
|
61
|
+
// Walk the (small, pruned) queue rather than correlating every message
|
|
62
|
+
// against it.
|
|
63
|
+
if (tableExists(db, 'queue') && columnNames(db, 'queue').has('response_message_id')) {
|
|
64
|
+
const link = db.prepare(`
|
|
65
|
+
UPDATE messages SET source_message_id = ?
|
|
66
|
+
WHERE id = ? AND role = 'assistant' AND source_message_id IS NULL
|
|
67
|
+
`);
|
|
68
|
+
const rows = db.prepare(`
|
|
69
|
+
SELECT id, response_message_id FROM queue
|
|
70
|
+
WHERE response_message_id IS NOT NULL AND response_message_id != ''
|
|
71
|
+
`).all();
|
|
72
|
+
for (const row of rows) {
|
|
73
|
+
sourceLinksBackfilled += link.run(String(row.id), String(row.response_message_id)).changes;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (columns.has('kind')) {
|
|
77
|
+
foldStubsRekinded = db.prepare(`
|
|
78
|
+
UPDATE messages SET kind = 'folded'
|
|
79
|
+
WHERE role = 'assistant' AND kind = 'absorbed' AND TRIM(text) = ?
|
|
80
|
+
`).run(STEER_FOLDED_TEXT).changes;
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
return {
|
|
84
|
+
applied: columnAdded || sourceLinksBackfilled > 0 || foldStubsRekinded > 0,
|
|
85
|
+
columnAdded,
|
|
86
|
+
sourceLinksBackfilled,
|
|
87
|
+
foldStubsRekinded,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function migrate(dbPath) {
|
|
92
|
+
const db = new Database(dbPath);
|
|
93
|
+
try {
|
|
94
|
+
return migrateSteerSettleMarkers(db);
|
|
95
|
+
} finally {
|
|
96
|
+
db.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function defaultDbPath() {
|
|
101
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
102
|
+
return path.resolve(here, '..', 'data', 'copilot.db');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
106
|
+
if (isMain) {
|
|
107
|
+
const dbPath = process.argv[2] ? path.resolve(process.argv[2]) : defaultDbPath();
|
|
108
|
+
try {
|
|
109
|
+
const result = migrate(dbPath);
|
|
110
|
+
console.log(`Migration 0004 on ${dbPath}`);
|
|
111
|
+
console.log(result.applied
|
|
112
|
+
? ` column added: ${result.columnAdded}; source links backfilled: ${result.sourceLinksBackfilled}; fold stubs re-kinded: ${result.foldStubsRekinded}`
|
|
113
|
+
: ` skipped: ${result.reason || 'nothing to change'}`);
|
|
114
|
+
process.exit(0);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(`Migration 0004 failed: ${err?.message || err}`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
}
|