@oar-sh/oar 0.9.12 → 0.9.14
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 +9 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
- package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
- package/CHANGELOG.md +508 -0
- package/README.md +12 -12
- package/bin/oar.js +155 -94
- package/package.json +5 -5
- package/server/README.md +479 -157
- package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
- package/server/claude-worker/claude-session-process.mjs +92 -15
- package/server/claude-worker/claude-turn-publisher.mjs +20 -1
- package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
- package/server/claude-worker/task-output-discovery.mjs +28 -2
- package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
- package/server/copilot-worker/copilot-continuation-signals.mjs +11 -5
- package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
- package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
- package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +24 -1
- package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
- package/server/cursor-worker/cursor-session-worker.mjs +9 -2
- package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
- package/server/db-schema.mjs +10 -0
- package/server/grok-worker/acp-host-services.mjs +6 -3
- package/server/grok-worker/grok-background-tasks.mjs +7 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
- package/server/grok-worker/grok-turn-runner.mjs +13 -3
- package/server/migrations/0018-background-task-rows.mjs +101 -0
- package/server/public/app/action-confirmations.js +12 -15
- package/server/public/app/api-client.js +60 -4
- package/server/public/app/ask-user-view.js +29 -4
- package/server/public/app/background-tasks-view.mjs +56 -7
- package/server/public/app/bootstrap.js +7 -0
- package/server/public/app/conversation-preferences.mjs +10 -4
- package/server/public/app/conversation-provider-indicator.mjs +18 -8
- package/server/public/app/conversation-view.js +33 -9
- package/server/public/app/host-suspend-ui.js +45 -44
- package/server/public/app/journal-view.js +9 -3
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/question-answer-rule.mjs +15 -0
- package/server/public/app/question-multi-select.mjs +9 -2
- package/server/public/app/relay-event-view.mjs +5 -1
- package/server/public/app/relay-prompt-sanitizer.mjs +9 -2
- package/server/public/app/relay-restart-note-view.mjs +36 -0
- package/server/public/app/relay-restart-ui.js +568 -0
- package/server/public/app/settings-modal.js +52 -0
- package/server/public/app/socket-handlers.js +1 -1
- package/server/public/app/task-console-render.mjs +171 -7
- package/server/public/app/task-console.mjs +111 -11
- package/server/public/app/task-output-text.mjs +15 -6
- package/server/public/app/transcript-jump-bottom-view.mjs +5 -1
- package/server/public/app/transcript-jump-bottom.mjs +20 -0
- package/server/public/index.html +76 -5
- package/server/relay-exit-codes.mjs +6 -0
- package/server/relay-self-restart.mjs +6 -1
- package/server/relay-tools.md +17 -9
- package/server/repositories/message-repository.mjs +32 -28
- package/server/repositories/remote-relay-repository.mjs +4 -2
- package/server/repositories/subagent-timeline-statements.mjs +2 -0
- package/server/routes/ask-user-routes.mjs +41 -7
- package/server/routes/messages-routes.mjs +220 -37
- package/server/routes/preview-routes.mjs +73 -7
- package/server/routes/relay-board-routes.mjs +19 -2
- package/server/routes/relay-restart-routes.mjs +73 -0
- package/server/routes/sessions-routes.mjs +79 -5
- package/server/server-runtime.mjs +335 -218
- package/server/services/about-service.mjs +13 -12
- package/server/services/background-task-persistence.mjs +133 -0
- package/server/services/background-task-store.mjs +240 -13
- package/server/services/conversation-model-fallback.mjs +57 -0
- package/server/services/cursor-session-root-service.mjs +70 -6
- package/server/services/data-dir-resolution.mjs +208 -19
- package/server/services/earlier-database-service.mjs +631 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/oar-cli-helpers.mjs +6 -1
- package/server/services/oar-state-layout.mjs +107 -0
- package/server/services/oar-state-migration-service.mjs +387 -96
- package/server/services/package-data-rescue-preinstall.mjs +22 -0
- package/server/services/package-data-rescue.mjs +137 -0
- package/server/services/preview-proxy-server.mjs +14 -1
- package/server/services/preview-registry-service.mjs +3 -2
- package/server/services/process-list-service.mjs +2 -1
- package/server/services/provider-model-selection.mjs +195 -0
- package/server/services/push-dispatch-service.mjs +42 -8
- package/server/services/relay-event-service.mjs +210 -14
- package/server/services/relay-prompt-sanitizer.mjs +7 -0
- package/server/services/relay-restart-picture.mjs +209 -0
- package/server/services/relay-restart-queue.mjs +387 -0
- package/server/services/relay-shutdown-gate.mjs +4 -1
- package/server/services/relay-singleton-guard.mjs +300 -36
- package/server/services/remote-relay-caller-context.mjs +7 -2
- package/server/services/remote-relay-dispatcher.mjs +217 -28
- package/server/services/restart-resume-service.mjs +303 -0
- package/server/services/session-activity-tracker.mjs +52 -15
- package/server/services/session-worker-launch-service.mjs +31 -1
- package/server/services/session-worker-process-service.mjs +6 -4
- package/server/services/stale-turn-recovery.mjs +99 -0
- package/server/services/subagent-timeline-service.mjs +4 -1
- package/server/services/task-output-service.mjs +217 -18
- package/server/services/windows-autostart-service.mjs +329 -31
- package/server/services/windows-boot-autostart-service.mjs +188 -60
- package/shared/preview-instructions.mjs +17 -12
- package/shared/preview-tool-core.mjs +32 -14
- package/shared/question-answer-rule.mjs +63 -0
- package/shared/relay-events.mjs +106 -3
- package/shared/remote-relay-contract.mjs +32 -15
- package/shared/subagent-timeline.mjs +4 -0
- package/shared/task-output-text.mjs +196 -83
- package/shared/windows-system-tools.mjs +25 -0
- package/shared/worker-runtime/config-loader.mjs +54 -8
- package/shared/worker-runtime/prompt-context.mjs +3 -1
- package/shared/worker-runtime/worker-websocket-link.mjs +96 -20
- package/shared/worker-runtime/working-directory.mjs +65 -0
- package/server/uploads/image_input.md +0 -540
|
@@ -68,32 +68,33 @@ const COMMIT_PATTERN = /^[0-9a-f]{40}$/i;
|
|
|
68
68
|
* The commit a git checkout has checked out, read from its .git files rather
|
|
69
69
|
* than from a `git` process: HEAD, then the loose ref, then packed-refs. A
|
|
70
70
|
* linked worktree's .git is a file naming its git dir, whose refs live in the
|
|
71
|
-
* common dir. Anything unexpected reads as null.
|
|
71
|
+
* common dir. Anything unexpected reads as null. `pathImpl` is the host's path
|
|
72
|
+
* module; tests pass path.posix and path.win32.
|
|
72
73
|
*/
|
|
73
|
-
export function readGitCommit(root, { fsImpl = fs } = {}) {
|
|
74
|
+
export function readGitCommit(root, { fsImpl = fs, pathImpl = path } = {}) {
|
|
74
75
|
if (!root) return null;
|
|
75
|
-
const dotGit =
|
|
76
|
+
const dotGit = pathImpl.join(root, '.git');
|
|
76
77
|
let gitDir = dotGit;
|
|
77
78
|
const pointer = readFileText(fsImpl, dotGit);
|
|
78
79
|
if (pointer !== null) {
|
|
79
80
|
const match = pointer.match(/^gitdir:\s*(.+)$/m);
|
|
80
81
|
if (!match) return null;
|
|
81
|
-
gitDir =
|
|
82
|
+
gitDir = pathImpl.resolve(root, match[1].trim());
|
|
82
83
|
}
|
|
83
|
-
const head = readFileText(fsImpl,
|
|
84
|
+
const head = readFileText(fsImpl, pathImpl.join(gitDir, 'HEAD'));
|
|
84
85
|
if (head === null) return null;
|
|
85
86
|
const headText = head.trim();
|
|
86
87
|
if (COMMIT_PATTERN.test(headText)) return headText.toLowerCase();
|
|
87
88
|
const refMatch = headText.match(/^ref:\s*(\S+)$/);
|
|
88
89
|
if (!refMatch) return null;
|
|
89
90
|
const ref = refMatch[1];
|
|
90
|
-
const commonDirText = readFileText(fsImpl,
|
|
91
|
-
const commonDir = commonDirText ?
|
|
91
|
+
const commonDirText = readFileText(fsImpl, pathImpl.join(gitDir, 'commondir'));
|
|
92
|
+
const commonDir = commonDirText ? pathImpl.resolve(gitDir, commonDirText.trim()) : gitDir;
|
|
92
93
|
for (const dir of new Set([gitDir, commonDir])) {
|
|
93
|
-
const loose = readFileText(fsImpl,
|
|
94
|
+
const loose = readFileText(fsImpl, pathImpl.join(dir, ...ref.split('/')));
|
|
94
95
|
if (loose && COMMIT_PATTERN.test(loose.trim())) return loose.trim().toLowerCase();
|
|
95
96
|
}
|
|
96
|
-
const packed = readFileText(fsImpl,
|
|
97
|
+
const packed = readFileText(fsImpl, pathImpl.join(commonDir, 'packed-refs')) || '';
|
|
97
98
|
for (const line of packed.split(/\r?\n/)) {
|
|
98
99
|
const [hash, name] = line.trim().split(/\s+/);
|
|
99
100
|
if (name === ref && COMMIT_PATTERN.test(hash || '')) return hash.toLowerCase();
|
|
@@ -102,11 +103,11 @@ export function readGitCommit(root, { fsImpl = fs } = {}) {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
/** The commit this build came from: the checkout's HEAD, else a `gitHead` stamp in package.json. */
|
|
105
|
-
export function resolveBuildCommit(packageRoot, { fsImpl = fs } = {}) {
|
|
106
|
-
const fromGit = readGitCommit(packageRoot, { fsImpl });
|
|
106
|
+
export function resolveBuildCommit(packageRoot, { fsImpl = fs, pathImpl = path } = {}) {
|
|
107
|
+
const fromGit = readGitCommit(packageRoot, { fsImpl, pathImpl });
|
|
107
108
|
if (fromGit) return fromGit;
|
|
108
109
|
try {
|
|
109
|
-
const stamp = String(JSON.parse(readFileText(fsImpl,
|
|
110
|
+
const stamp = String(JSON.parse(readFileText(fsImpl, pathImpl.join(packageRoot, 'package.json')) || '{}')?.gitHead || '').trim();
|
|
110
111
|
return COMMIT_PATTERN.test(stamp) ? stamp.toLowerCase() : null;
|
|
111
112
|
} catch {
|
|
112
113
|
return null;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { backgroundTaskRowsTableProblem } from '../migrations/0018-background-task-rows.mjs';
|
|
4
|
+
|
|
5
|
+
// Persistence for the background-task store's console rows: the
|
|
6
|
+
// `background_task_rows` table (migration 0018) behind a small adapter, so
|
|
7
|
+
// the store restores its rows after a restart and stays testable without a
|
|
8
|
+
// database. Without a usable table every call is a no-op and the store keeps
|
|
9
|
+
// its rows in memory only, as before; a problem is logged once and never
|
|
10
|
+
// stops the relay.
|
|
11
|
+
|
|
12
|
+
function unavailable(reason) {
|
|
13
|
+
return {
|
|
14
|
+
available: false,
|
|
15
|
+
reason,
|
|
16
|
+
load: () => [],
|
|
17
|
+
saveLive: () => false,
|
|
18
|
+
saveEnded: () => false,
|
|
19
|
+
remove: () => false,
|
|
20
|
+
forget: () => false,
|
|
21
|
+
pruneEnded: () => 0,
|
|
22
|
+
touchLive: () => false,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseRow(text) {
|
|
27
|
+
try {
|
|
28
|
+
const row = JSON.parse(String(text || ''));
|
|
29
|
+
return row && typeof row === 'object' && !Array.isArray(row) ? row : null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A row as stored: no health reading (the poller reads the file again). */
|
|
36
|
+
function storedRowJson(row) {
|
|
37
|
+
const { health: _health, ...rest } = row || {};
|
|
38
|
+
return JSON.stringify(rest);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createSqliteBackgroundTaskPersistence(db, { logger = console, now = () => Date.now() } = {}) {
|
|
42
|
+
let stmts;
|
|
43
|
+
try {
|
|
44
|
+
const problem = backgroundTaskRowsTableProblem(db);
|
|
45
|
+
if (problem) return unavailable(problem);
|
|
46
|
+
stmts = {
|
|
47
|
+
list: db.prepare(`SELECT store_key, task_id, state, row_json, ended_at, updated_at FROM background_task_rows`),
|
|
48
|
+
touchLive: db.prepare(`UPDATE background_task_rows SET updated_at = ? WHERE store_key = ? AND state = 'live'`),
|
|
49
|
+
upsert: db.prepare(`
|
|
50
|
+
INSERT INTO background_task_rows (store_key, task_id, state, row_json, ended_at, updated_at)
|
|
51
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
52
|
+
ON CONFLICT(store_key, task_id) DO UPDATE SET
|
|
53
|
+
state = excluded.state, row_json = excluded.row_json, ended_at = excluded.ended_at, updated_at = excluded.updated_at
|
|
54
|
+
`),
|
|
55
|
+
liveOfKey: db.prepare(`SELECT task_id FROM background_task_rows WHERE store_key = ? AND state = 'live'`),
|
|
56
|
+
remove: db.prepare(`DELETE FROM background_task_rows WHERE store_key = ? AND task_id = ?`),
|
|
57
|
+
removeLive: db.prepare(`DELETE FROM background_task_rows WHERE store_key = ? AND task_id = ? AND state = 'live'`),
|
|
58
|
+
forget: db.prepare(`DELETE FROM background_task_rows WHERE store_key = ?`),
|
|
59
|
+
prune: db.prepare(`DELETE FROM background_task_rows WHERE state = 'ended' AND ended_at < ?`),
|
|
60
|
+
};
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const reason = error?.message || String(error);
|
|
63
|
+
logger?.warn?.(`[background-tasks] console rows are not persisted: ${reason}`);
|
|
64
|
+
return unavailable(reason);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// One warning per kind of failure: a write that fails keeps failing, and
|
|
68
|
+
// the store goes on in memory either way.
|
|
69
|
+
const warned = new Set();
|
|
70
|
+
function guard(what, run, fallback) {
|
|
71
|
+
try {
|
|
72
|
+
return run();
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (!warned.has(what)) {
|
|
75
|
+
warned.add(what);
|
|
76
|
+
logger?.warn?.(`[background-tasks] could not ${what} a console row: ${error?.message || error}`);
|
|
77
|
+
}
|
|
78
|
+
return fallback;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const saveLiveTx = db.transaction((key, rows) => {
|
|
83
|
+
const keep = new Set(rows.map((row) => String(row.taskId)));
|
|
84
|
+
for (const { task_id: taskId } of stmts.liveOfKey.all(key)) {
|
|
85
|
+
if (!keep.has(taskId)) stmts.removeLive.run(key, taskId);
|
|
86
|
+
}
|
|
87
|
+
const at = now();
|
|
88
|
+
for (const row of rows) stmts.upsert.run(key, String(row.taskId), 'live', storedRowJson(row), null, at);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
available: true,
|
|
93
|
+
reason: '',
|
|
94
|
+
/** Every stored row: `{ key, taskId, state, row, endedAt, updatedAt }`. */
|
|
95
|
+
load() {
|
|
96
|
+
return guard('read', () => stmts.list.all().map((entry) => ({
|
|
97
|
+
key: String(entry.store_key),
|
|
98
|
+
taskId: String(entry.task_id),
|
|
99
|
+
state: String(entry.state),
|
|
100
|
+
row: parseRow(entry.row_json),
|
|
101
|
+
endedAt: Number.isFinite(Number(entry.ended_at)) && entry.ended_at !== null ? Number(entry.ended_at) : null,
|
|
102
|
+
updatedAt: Number.isFinite(Number(entry.updated_at)) ? Number(entry.updated_at) : null,
|
|
103
|
+
})).filter((entry) => entry.row), []);
|
|
104
|
+
},
|
|
105
|
+
/** The key's live console rows are exactly these (an ended row of the same task is replaced). */
|
|
106
|
+
saveLive(key, rows) {
|
|
107
|
+
return guard('write', () => { saveLiveTx(String(key), Array.isArray(rows) ? rows : []); return true; }, false);
|
|
108
|
+
},
|
|
109
|
+
saveEnded(key, row) {
|
|
110
|
+
return guard('write', () => {
|
|
111
|
+
stmts.upsert.run(String(key), String(row.taskId), 'ended', storedRowJson(row), Number(row.endedAt) || now(), now());
|
|
112
|
+
return true;
|
|
113
|
+
}, false);
|
|
114
|
+
},
|
|
115
|
+
remove(key, taskId) {
|
|
116
|
+
return guard('delete', () => stmts.remove.run(String(key), String(taskId)).changes > 0, false);
|
|
117
|
+
},
|
|
118
|
+
forget(keys) {
|
|
119
|
+
return guard('delete', () => {
|
|
120
|
+
for (const key of keys) stmts.forget.run(String(key));
|
|
121
|
+
return true;
|
|
122
|
+
}, false);
|
|
123
|
+
},
|
|
124
|
+
/** The key's live rows were confirmed running now (its worker's digest agreed). */
|
|
125
|
+
touchLive(key) {
|
|
126
|
+
return guard('write', () => stmts.touchLive.run(now(), String(key)).changes > 0, false);
|
|
127
|
+
},
|
|
128
|
+
/** Drops the ended rows that ended before `cutoff` (epoch ms). */
|
|
129
|
+
pruneEnded(cutoff) {
|
|
130
|
+
return guard('prune', () => stmts.prune.run(Number(cutoff) || 0).changes, 0);
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -22,6 +22,17 @@
|
|
|
22
22
|
* Health: the health poller's last reading per live task (`setHealth`), served
|
|
23
23
|
* with the task rows (`withHealth`) and carried onto the ended row, where the
|
|
24
24
|
* poller's reading after the end replaces it (`setEndedHealth`).
|
|
25
|
+
*
|
|
26
|
+
* Across a restart (`persistence`, the `background_task_rows` table): the
|
|
27
|
+
* console rows survive, the live sets do not. Ended rows come back as they
|
|
28
|
+
* were, for the rest of their hour. A live task with a console comes back
|
|
29
|
+
* held (`restored`): its console stays readable, but no panel, state or
|
|
30
|
+
* suspend check counts it as running, because only the worker can say that.
|
|
31
|
+
* The worker's next set settles it: a task in the set is live again, one not
|
|
32
|
+
* in it ended while the relay was away. A worker that never reports (it died
|
|
33
|
+
* with the relay) has its held tasks marked ended (`endUnconfirmed`), never
|
|
34
|
+
* running again. Health readings are not stored: the poller reads the files
|
|
35
|
+
* again. Sub-agent rows have no console and are not stored either.
|
|
25
36
|
*/
|
|
26
37
|
|
|
27
38
|
import { backgroundTaskDigest, sameBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
|
|
@@ -52,10 +63,29 @@ function exitCodeOf(value) {
|
|
|
52
63
|
return Number.isInteger(number) ? number : null;
|
|
53
64
|
}
|
|
54
65
|
|
|
66
|
+
// A stored live row is confirmed at most this often while its worker's
|
|
67
|
+
// digest agrees (its `updated_at`), and one not confirmed for this long is a
|
|
68
|
+
// leftover of a worker that went away without a word: not restored.
|
|
69
|
+
const LIVE_TOUCH_EVERY_MS = 30 * 60_000;
|
|
70
|
+
const LIVE_STALE_MS = 6 * 60 * 60_000;
|
|
71
|
+
|
|
72
|
+
// What of a live console task is stored: what the console and an ended row
|
|
73
|
+
// need, not what changes with every progress post.
|
|
74
|
+
const STORED_LIVE_FIELDS = ['taskId', 'taskType', 'description', 'command', 'startedAt', 'outputPath', 'outputKind', 'stoppable', 'toolUseId'];
|
|
75
|
+
|
|
76
|
+
function storedLiveRow(task, ahead) {
|
|
77
|
+
const row = {};
|
|
78
|
+
for (const field of STORED_LIVE_FIELDS) if (task?.[field] !== undefined && task?.[field] !== null) row[field] = task[field];
|
|
79
|
+
// How it ended, told before the set dropped it.
|
|
80
|
+
if (ahead) row.settled = { status: ahead.status, exitCode: ahead.exitCode, ...(ahead.steps !== undefined ? { steps: ahead.steps } : {}) };
|
|
81
|
+
return row;
|
|
82
|
+
}
|
|
83
|
+
|
|
55
84
|
export function createBackgroundTaskStore({
|
|
56
85
|
now = () => Date.now(),
|
|
57
86
|
endedKeepMs = TASK_OUTPUT_LIMITS.endedKeepMs,
|
|
58
87
|
endedMax = TASK_OUTPUT_LIMITS.endedMax,
|
|
88
|
+
persistence = null,
|
|
59
89
|
} = {}) {
|
|
60
90
|
const sets = new Map();
|
|
61
91
|
// key → Map(taskId → ended row), newest last.
|
|
@@ -75,6 +105,18 @@ export function createBackgroundTaskStore({
|
|
|
75
105
|
const holders = new Map();
|
|
76
106
|
// Keys whose pages were told of the placeholder for unreported work.
|
|
77
107
|
const announced = new Set();
|
|
108
|
+
// key → Map(taskId → row): live console tasks of the runtime before, held
|
|
109
|
+
// until the worker's set says what became of them.
|
|
110
|
+
const restored = new Map();
|
|
111
|
+
// key → what was stored of its live console tasks last, to write only on change.
|
|
112
|
+
const storedLive = new Map();
|
|
113
|
+
// key → when its stored live rows were last written or confirmed.
|
|
114
|
+
const touchedAt = new Map();
|
|
115
|
+
// key → Map(taskId → when the runtime before last knew the held task running).
|
|
116
|
+
const heldSince = new Map();
|
|
117
|
+
const store = persistence && persistence.available !== false ? persistence : null;
|
|
118
|
+
|
|
119
|
+
restore();
|
|
78
120
|
|
|
79
121
|
return {
|
|
80
122
|
sets,
|
|
@@ -97,9 +139,33 @@ export function createBackgroundTaskStore({
|
|
|
97
139
|
health: healthByKey.get(id)?.get(taskId) || task.health || undefined,
|
|
98
140
|
});
|
|
99
141
|
}
|
|
142
|
+
// Tasks held since the restart: the worker's set says which still run
|
|
143
|
+
// (their console is the one they had; the first path a task names
|
|
144
|
+
// wins), and the others ended while the relay was away.
|
|
145
|
+
const held = restored.get(id);
|
|
146
|
+
if (held) {
|
|
147
|
+
restored.delete(id);
|
|
148
|
+
for (const [taskId, row] of held) {
|
|
149
|
+
const live = next.find((task) => keyOf(task?.taskId) === taskId);
|
|
150
|
+
if (live) {
|
|
151
|
+
if (row.outputPath && live.outputPath !== row.outputPath) {
|
|
152
|
+
live.outputPath = row.outputPath;
|
|
153
|
+
live.outputKind = row.outputKind;
|
|
154
|
+
}
|
|
155
|
+
if (row.settled && !settledAhead.get(id)?.has(taskId)) {
|
|
156
|
+
let ahead = settledAhead.get(id);
|
|
157
|
+
if (!ahead) settledAhead.set(id, ahead = new Map());
|
|
158
|
+
ahead.set(taskId, { ...row.settled, at: now() });
|
|
159
|
+
}
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
endRestored(id, row);
|
|
163
|
+
}
|
|
164
|
+
heldSince.delete(id);
|
|
165
|
+
}
|
|
100
166
|
// A task back in the set (a resync after a restart) is not ended.
|
|
101
167
|
for (const taskId of nextIds) {
|
|
102
|
-
endedRows.get(id)?.delete(taskId);
|
|
168
|
+
if (endedRows.get(id)?.delete(taskId)) store?.remove(id, taskId);
|
|
103
169
|
settledAhead.get(id)?.delete(taskId);
|
|
104
170
|
}
|
|
105
171
|
const health = healthByKey.get(id);
|
|
@@ -107,11 +173,9 @@ export function createBackgroundTaskStore({
|
|
|
107
173
|
for (const taskId of [...health.keys()]) if (!nextIds.has(taskId)) health.delete(taskId);
|
|
108
174
|
if (!health.size) healthByKey.delete(id);
|
|
109
175
|
}
|
|
110
|
-
if (next.length === 0)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
sets.set(id, { tasks: next, updatedAt: now() });
|
|
176
|
+
if (next.length === 0) sets.delete(id);
|
|
177
|
+
else sets.set(id, { tasks: next, updatedAt: now() });
|
|
178
|
+
storeLive(id);
|
|
115
179
|
},
|
|
116
180
|
/**
|
|
117
181
|
* How a task ended (`/api/background-task-settled`): updates its ended
|
|
@@ -119,7 +183,7 @@ export function createBackgroundTaskStore({
|
|
|
119
183
|
* runtime never saw live that names its output, becomes an ended row of
|
|
120
184
|
* its own. Returns the ended row when there is one.
|
|
121
185
|
*/
|
|
122
|
-
settle(conversationId, taskId, { status, exitCode, outputPath, description, taskType, startedAt, outputKind, steps } = {}) {
|
|
186
|
+
settle(conversationId, taskId, { status, exitCode, outputPath, description, command, taskType, startedAt, outputKind, steps } = {}) {
|
|
123
187
|
const id = keyOf(conversationId);
|
|
124
188
|
const task = keyOf(taskId);
|
|
125
189
|
if (!id || !task) return null;
|
|
@@ -136,8 +200,21 @@ export function createBackgroundTaskStore({
|
|
|
136
200
|
if (existing.outputKind !== 'file' && existing.outputKind !== 'spool') existing.outputKind = outputKind === 'spool' ? 'spool' : 'file';
|
|
137
201
|
}
|
|
138
202
|
if (stepCount !== undefined) existing.steps = stepCount;
|
|
203
|
+
store?.saveEnded(id, existing);
|
|
139
204
|
return existing;
|
|
140
205
|
}
|
|
206
|
+
// A task held since the restart that ended while the relay was away
|
|
207
|
+
// (the worker sends the settle again): it is not running any more.
|
|
208
|
+
const heldRow = restored.get(id)?.get(task);
|
|
209
|
+
if (heldRow) {
|
|
210
|
+
restored.get(id).delete(task);
|
|
211
|
+
if (!restored.get(id).size) restored.delete(id);
|
|
212
|
+
return endRestored(id, {
|
|
213
|
+
...heldRow,
|
|
214
|
+
settled: { status: normalized, exitCode: code, ...(stepCount !== undefined ? { steps: stepCount } : {}) },
|
|
215
|
+
...(!heldRow.outputPath && outputPath ? { outputPath, outputKind: outputKind === 'spool' ? 'spool' : 'file' } : {}),
|
|
216
|
+
});
|
|
217
|
+
}
|
|
141
218
|
const live = (sets.get(id)?.tasks || []).find((entry) => keyOf(entry?.taskId) === task);
|
|
142
219
|
if (live) {
|
|
143
220
|
let ahead = settledAhead.get(id);
|
|
@@ -149,6 +226,7 @@ export function createBackgroundTaskStore({
|
|
|
149
226
|
live.outputPath = outputPath;
|
|
150
227
|
if (live.outputKind !== 'file' && live.outputKind !== 'spool') live.outputKind = outputKind === 'spool' ? 'spool' : 'file';
|
|
151
228
|
}
|
|
229
|
+
storeLive(id);
|
|
152
230
|
return null;
|
|
153
231
|
}
|
|
154
232
|
if (!outputPath) return null;
|
|
@@ -156,6 +234,7 @@ export function createBackgroundTaskStore({
|
|
|
156
234
|
taskId: task,
|
|
157
235
|
taskType: keyOf(taskType) || 'local_bash',
|
|
158
236
|
description: String(description || '').trim().slice(0, 500),
|
|
237
|
+
...(command ? { command } : {}),
|
|
159
238
|
startedAt: Number(startedAt) || null,
|
|
160
239
|
outputPath,
|
|
161
240
|
outputKind: outputKind || 'file',
|
|
@@ -202,6 +281,7 @@ export function createBackgroundTaskStore({
|
|
|
202
281
|
setEndedHealth(conversationId, taskId, value) {
|
|
203
282
|
const row = endedRows.get(keyOf(conversationId))?.get(keyOf(taskId));
|
|
204
283
|
if (!row || !value) return false;
|
|
284
|
+
// Not stored: the poller reads the file again after a restart.
|
|
205
285
|
row.health = value;
|
|
206
286
|
return true;
|
|
207
287
|
},
|
|
@@ -234,6 +314,15 @@ export function createBackgroundTaskStore({
|
|
|
234
314
|
const row = endedRows.get(id)?.get(task);
|
|
235
315
|
if (row) return { key: id, task: { ...row }, ended: true };
|
|
236
316
|
}
|
|
317
|
+
// Held since the restart: its console is readable while the worker
|
|
318
|
+
// has not reported yet.
|
|
319
|
+
for (const id of ids) {
|
|
320
|
+
const row = restored.get(id)?.get(task);
|
|
321
|
+
if (row) {
|
|
322
|
+
const { settled: _settled, ...rest } = row;
|
|
323
|
+
return { key: id, task: { ...rest }, ended: false, restored: true };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
237
326
|
return null;
|
|
238
327
|
},
|
|
239
328
|
get(conversationId) {
|
|
@@ -249,8 +338,20 @@ export function createBackgroundTaskStore({
|
|
|
249
338
|
* they agree. Agreement counts as a report, an empty set included.
|
|
250
339
|
*/
|
|
251
340
|
matches(digest, ...keys) {
|
|
341
|
+
// Tasks held since the restart are settled only by a set the worker
|
|
342
|
+
// sends: until then the worker is asked for it, whatever its digest.
|
|
343
|
+
if (keys.map(keyOf).some((key) => key && restored.has(key))) return false;
|
|
252
344
|
const same = sameBackgroundTaskDigest(digest, this.digestFor(...keys));
|
|
253
|
-
if (same)
|
|
345
|
+
if (same) {
|
|
346
|
+
for (const key of keys.map(keyOf).filter(Boolean)) {
|
|
347
|
+
reported.add(key);
|
|
348
|
+
// Its stored rows are still running: say so now and then, so a
|
|
349
|
+
// restart can tell them from a dead worker's leftovers.
|
|
350
|
+
if (store && storedLive.get(key) && now() - (touchedAt.get(key) || 0) >= LIVE_TOUCH_EVERY_MS) {
|
|
351
|
+
if (store.touchLive?.(key)) touchedAt.set(key, now());
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
254
355
|
return same;
|
|
255
356
|
},
|
|
256
357
|
/** Whether this runtime has heard from the worker about its tasks. */
|
|
@@ -305,7 +406,40 @@ export function createBackgroundTaskStore({
|
|
|
305
406
|
else announced.delete(id);
|
|
306
407
|
return true;
|
|
307
408
|
},
|
|
308
|
-
/**
|
|
409
|
+
/**
|
|
410
|
+
* Read only: every task held since the restart, as `{ key, task }`
|
|
411
|
+
* (the stored row). Not running for any panel or check until the
|
|
412
|
+
* worker's set confirms it.
|
|
413
|
+
*/
|
|
414
|
+
listRestored() {
|
|
415
|
+
const rows = [];
|
|
416
|
+
for (const [key, held] of restored) {
|
|
417
|
+
for (const row of held.values()) {
|
|
418
|
+
const { settled, ...task } = row;
|
|
419
|
+
rows.push({ key, task });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return rows;
|
|
423
|
+
},
|
|
424
|
+
/** Whether tasks of these keys are held since the restart. */
|
|
425
|
+
hasRestored(...keys) {
|
|
426
|
+
return keys.map(keyOf).some((key) => key && restored.has(key));
|
|
427
|
+
},
|
|
428
|
+
/**
|
|
429
|
+
* The workers that did not report since the restart: their held tasks
|
|
430
|
+
* are marked ended (how is unknown), never running again. Returns the
|
|
431
|
+
* keys whose ended rows changed.
|
|
432
|
+
*/
|
|
433
|
+
endUnconfirmed() {
|
|
434
|
+
const changed = [];
|
|
435
|
+
for (const [id, held] of [...restored]) {
|
|
436
|
+
restored.delete(id);
|
|
437
|
+
for (const row of held.values()) endRestored(id, row);
|
|
438
|
+
storeLive(id);
|
|
439
|
+
changed.push(id);
|
|
440
|
+
}
|
|
441
|
+
return changed;
|
|
442
|
+
},
|
|
309
443
|
/**
|
|
310
444
|
* Drops expired ended rows and pending settles of every key, also of
|
|
311
445
|
* conversations nobody reads again (the per-key prune runs on reads).
|
|
@@ -313,9 +447,17 @@ export function createBackgroundTaskStore({
|
|
|
313
447
|
*/
|
|
314
448
|
pruneAll() {
|
|
315
449
|
for (const id of new Set([...endedRows.keys(), ...settledAhead.keys()])) prune(id);
|
|
450
|
+
store?.pruneEnded(now() - endedKeepMs);
|
|
316
451
|
},
|
|
452
|
+
/** The conversation is gone: everything held for these keys goes. */
|
|
317
453
|
forget(...keys) {
|
|
318
|
-
|
|
454
|
+
const ids = keys.map(keyOf).filter(Boolean);
|
|
455
|
+
if (ids.length) store?.forget(ids);
|
|
456
|
+
for (const id of ids) {
|
|
457
|
+
restored.delete(id);
|
|
458
|
+
heldSince.delete(id);
|
|
459
|
+
touchedAt.delete(id);
|
|
460
|
+
storedLive.delete(id);
|
|
319
461
|
sets.delete(id);
|
|
320
462
|
reported.delete(id);
|
|
321
463
|
applied.delete(id);
|
|
@@ -328,6 +470,86 @@ export function createBackgroundTaskStore({
|
|
|
328
470
|
},
|
|
329
471
|
};
|
|
330
472
|
|
|
473
|
+
// The rows the runtime before kept: ended rows for the rest of their
|
|
474
|
+
// hour, live console tasks held for the worker's word.
|
|
475
|
+
function restore() {
|
|
476
|
+
if (!store) return;
|
|
477
|
+
const cutoff = now() - endedKeepMs;
|
|
478
|
+
const ended = [];
|
|
479
|
+
for (const entry of store.load()) {
|
|
480
|
+
const id = keyOf(entry.key);
|
|
481
|
+
const taskId = keyOf(entry.taskId);
|
|
482
|
+
if (!id || !taskId) continue;
|
|
483
|
+
if (entry.state === 'ended') {
|
|
484
|
+
const endedAt = Number(entry.endedAt ?? entry.row.endedAt);
|
|
485
|
+
if (!Number.isFinite(endedAt) || endedAt < cutoff) continue;
|
|
486
|
+
ended.push({ id, row: { ...entry.row, taskId, endedAt, stoppable: false } });
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
if (entry.state !== 'live') continue;
|
|
490
|
+
// Not confirmed for hours: its worker went away in a runtime before
|
|
491
|
+
// without a word. Nothing to show, now or as an ended row.
|
|
492
|
+
const since = Number(entry.updatedAt);
|
|
493
|
+
if (!Number.isFinite(since) || now() - since > LIVE_STALE_MS) {
|
|
494
|
+
store.remove(id, taskId);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
let held = restored.get(id);
|
|
498
|
+
if (!held) restored.set(id, held = new Map());
|
|
499
|
+
held.set(taskId, { ...entry.row, taskId });
|
|
500
|
+
let sinceOf = heldSince.get(id);
|
|
501
|
+
if (!sinceOf) heldSince.set(id, sinceOf = new Map());
|
|
502
|
+
sinceOf.set(taskId, Math.min(since, now()));
|
|
503
|
+
storedLive.set(id, null);
|
|
504
|
+
}
|
|
505
|
+
// Oldest first, as `addEnded` keeps them.
|
|
506
|
+
ended.sort((a, b) => a.row.endedAt - b.row.endedAt);
|
|
507
|
+
for (const { id, row } of ended) {
|
|
508
|
+
let rows = endedRows.get(id);
|
|
509
|
+
if (!rows) endedRows.set(id, rows = new Map());
|
|
510
|
+
rows.set(row.taskId, row);
|
|
511
|
+
while (rows.size > endedMax) rows.delete(rows.keys().next().value);
|
|
512
|
+
}
|
|
513
|
+
store.pruneEnded(cutoff);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// A held task that is not running any more becomes an ended row, with how
|
|
517
|
+
// it ended when the runtime before was told.
|
|
518
|
+
// It ended at the latest when the runtime before last knew it running, so
|
|
519
|
+
// that is its end time: a leftover from long ago does not count as fresh.
|
|
520
|
+
function endRestored(id, row) {
|
|
521
|
+
const { settled, ...task } = row;
|
|
522
|
+
const since = heldSince.get(id)?.get(keyOf(task.taskId));
|
|
523
|
+
heldSince.get(id)?.delete(keyOf(task.taskId));
|
|
524
|
+
if (!heldSince.get(id)?.size) heldSince.delete(id);
|
|
525
|
+
return addEnded(id, {
|
|
526
|
+
...task,
|
|
527
|
+
...(settled?.steps !== undefined ? { steps: settled.steps } : {}),
|
|
528
|
+
status: settled?.status || 'unknown',
|
|
529
|
+
exitCode: settled ? settled.exitCode : null,
|
|
530
|
+
}, { endedAt: Number.isFinite(since) ? since : undefined });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Stores the key's live console tasks when they changed (what is stored,
|
|
534
|
+
// not every progress post).
|
|
535
|
+
function storeLive(id) {
|
|
536
|
+
if (!store) return;
|
|
537
|
+
const ahead = settledAhead.get(id);
|
|
538
|
+
const rows = (sets.get(id)?.tasks || [])
|
|
539
|
+
.filter((task) => task?.outputPath && keyOf(task?.taskId))
|
|
540
|
+
.map((task) => storedLiveRow(task, ahead?.get(keyOf(task.taskId))));
|
|
541
|
+
// Held tasks stay stored until the worker's set settles them.
|
|
542
|
+
for (const row of restored.get(id)?.values() || []) rows.push(row);
|
|
543
|
+
const signature = JSON.stringify(rows);
|
|
544
|
+
if (storedLive.get(id) === signature) return;
|
|
545
|
+
if (!rows.length && !storedLive.has(id)) return;
|
|
546
|
+
if (store.saveLive(id, rows)) {
|
|
547
|
+
touchedAt.set(id, now());
|
|
548
|
+
if (rows.length) storedLive.set(id, signature);
|
|
549
|
+
else storedLive.delete(id);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
331
553
|
function prune(id) {
|
|
332
554
|
const cutoff = now() - endedKeepMs;
|
|
333
555
|
const rows = endedRows.get(id);
|
|
@@ -342,7 +564,7 @@ export function createBackgroundTaskStore({
|
|
|
342
564
|
}
|
|
343
565
|
}
|
|
344
566
|
|
|
345
|
-
function addEnded(id, task) {
|
|
567
|
+
function addEnded(id, task, { endedAt = now() } = {}) {
|
|
346
568
|
let rows = endedRows.get(id);
|
|
347
569
|
if (!rows) endedRows.set(id, rows = new Map());
|
|
348
570
|
const taskId = keyOf(task.taskId);
|
|
@@ -352,13 +574,18 @@ export function createBackgroundTaskStore({
|
|
|
352
574
|
taskId,
|
|
353
575
|
status: normalizeEndedTaskStatus(task.status, exitCodeOf(task.exitCode)),
|
|
354
576
|
exitCode: exitCodeOf(task.exitCode),
|
|
355
|
-
endedAt
|
|
577
|
+
endedAt,
|
|
356
578
|
stoppable: false,
|
|
357
579
|
};
|
|
358
580
|
if (!row.health) delete row.health;
|
|
359
581
|
rows.set(taskId, row);
|
|
360
582
|
settledAhead.get(id)?.delete(taskId);
|
|
361
|
-
|
|
583
|
+
store?.saveEnded(id, row);
|
|
584
|
+
while (rows.size > endedMax) {
|
|
585
|
+
const oldest = rows.keys().next().value;
|
|
586
|
+
rows.delete(oldest);
|
|
587
|
+
store?.remove(id, oldest);
|
|
588
|
+
}
|
|
362
589
|
prune(id);
|
|
363
590
|
return row;
|
|
364
591
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// What a send to an existing conversation runs with when the request names no
|
|
4
|
+
// model or no effort. The answer is the conversation's own choice, never the
|
|
5
|
+
// relay-wide catalog default: a plain POST /api/message from a script or an
|
|
6
|
+
// agent used to switch a Copilot conversation to the catalog's first model and
|
|
7
|
+
// to effort 'none', and then saved that as the conversation's preference.
|
|
8
|
+
//
|
|
9
|
+
// The effort clamp is the composer's own (public/app/conversation-preferences.mjs),
|
|
10
|
+
// so the relay and the page settle on the same level for the same model.
|
|
11
|
+
import { nearestSupportedReasoningEffort } from '../public/app/conversation-preferences.mjs';
|
|
12
|
+
|
|
13
|
+
const AUTO_MODEL = 'auto';
|
|
14
|
+
|
|
15
|
+
function toText(value) {
|
|
16
|
+
return String(value ?? '').trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The model an existing conversation was last set to, in this order:
|
|
21
|
+
* 1. the conversation's saved preference (what the composer shows);
|
|
22
|
+
* 2. the runtime session's model (provider_model, else model);
|
|
23
|
+
* 3. the model of the latest user message.
|
|
24
|
+
* 'auto' is not a model a later turn can run on, so it is skipped. Null when
|
|
25
|
+
* the conversation has none of these: the caller falls back to the catalog.
|
|
26
|
+
*/
|
|
27
|
+
export function inheritedConversationModel({
|
|
28
|
+
conversation = null,
|
|
29
|
+
runtimeSession = null,
|
|
30
|
+
latestUserMessageModel = '',
|
|
31
|
+
} = {}) {
|
|
32
|
+
// provider_model and model are looked at one by one, so an 'auto' in one
|
|
33
|
+
// does not hide a real model in the other.
|
|
34
|
+
const candidates = [
|
|
35
|
+
{ source: 'conversation', model: toText(conversation?.preferred_model) },
|
|
36
|
+
{ source: 'runtime', model: toText(runtimeSession?.provider_model) },
|
|
37
|
+
{ source: 'runtime', model: toText(runtimeSession?.model) },
|
|
38
|
+
{ source: 'message', model: toText(latestUserMessageModel) },
|
|
39
|
+
];
|
|
40
|
+
return candidates.find((entry) => entry.model && entry.model.toLowerCase() !== AUTO_MODEL) || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The effort for a turn that names none: the first candidate the model
|
|
45
|
+
* supports (or the nearest level to it), else the model's lowest level that
|
|
46
|
+
* is not 'none'. 'none' comes back only when it is the only level, or when a
|
|
47
|
+
* candidate asks for it and the model has it. '' when the model lists no
|
|
48
|
+
* levels at all. A provider whose own default is better than "the lowest
|
|
49
|
+
* level" passes `fallback`, returned when no candidate matches or clamps.
|
|
50
|
+
*/
|
|
51
|
+
export function fallbackReasoningEffort(candidates = [], supportedEfforts = [], { fallback } = {}) {
|
|
52
|
+
return nearestSupportedReasoningEffort(
|
|
53
|
+
(Array.isArray(candidates) ? candidates : [candidates]).map(toText).filter(Boolean),
|
|
54
|
+
supportedEfforts,
|
|
55
|
+
{ fallback },
|
|
56
|
+
);
|
|
57
|
+
}
|