mixdog 0.9.24 → 0.9.26
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/package.json +2 -1
- package/scripts/build-runtime-windows.ps1 +242 -242
- package/scripts/explore-bench-tmp.mjs +1 -1
- package/scripts/recall-usecase-cases.json +1 -1
- package/scripts/smoke-runtime-negative.ps1 +106 -106
- package/scripts/steering-fold-provenance-test.mjs +71 -0
- package/scripts/tool-efficiency-diag.mjs +1 -1
- package/scripts/webhook-smoke.mjs +46 -53
- package/src/defaults/cycle3-review-prompt.md +11 -4
- package/src/defaults/memory-promote-prompt.md +9 -0
- package/src/defaults/skills/setup/SKILL.md +6 -1
- package/src/rules/lead/02-channels.md +3 -3
- package/src/rules/shared/01-tool.md +8 -4
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +17 -0
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +2 -2
- package/src/runtime/agent/orchestrator/mcp/client.mjs +28 -10
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +51 -15
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +5 -1
- package/src/runtime/agent/orchestrator/session/loop/steering-ladder.mjs +164 -9
- package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +45 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/shell-state.mjs +7 -3
- package/src/runtime/channels/backends/discord.mjs +10 -3
- package/src/runtime/channels/lib/config.mjs +13 -11
- package/src/runtime/channels/lib/inbound-handler.mjs +45 -1
- package/src/runtime/channels/lib/memory-client.mjs +22 -2
- package/src/runtime/channels/lib/owned-runtime.mjs +1 -1
- package/src/runtime/channels/lib/scheduler.mjs +226 -208
- package/src/runtime/channels/lib/status-snapshot.mjs +16 -0
- package/src/runtime/channels/lib/webhook/deliveries.mjs +7 -318
- package/src/runtime/channels/lib/webhook.mjs +98 -150
- package/src/runtime/channels/lib/worker-main.mjs +1 -1
- package/src/runtime/memory/index.mjs +50 -25
- package/src/runtime/memory/lib/cycle-scheduler.mjs +20 -2
- package/src/runtime/memory/lib/memory-config-flags.mjs +13 -1
- package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +59 -0
- package/src/runtime/memory/lib/memory-cycle2.mjs +10 -3
- package/src/runtime/memory/lib/memory-cycle3.mjs +79 -9
- package/src/runtime/memory/lib/pg/adapter.mjs +6 -1
- package/src/runtime/memory/lib/pg/process.mjs +19 -1
- package/src/runtime/memory/lib/pg/supervisor.mjs +125 -14
- package/src/runtime/memory/lib/query-handlers.mjs +102 -0
- package/src/runtime/memory/lib/recall-format.mjs +60 -22
- package/src/runtime/memory/lib/session-ingest-runtime.mjs +20 -6
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/child-guardian.mjs +2 -2
- package/src/runtime/shared/open-url.mjs +2 -1
- package/src/runtime/shared/schedules-db.mjs +350 -0
- package/src/runtime/shared/service-discovery.mjs +169 -0
- package/src/runtime/shared/spawn-flags.mjs +27 -0
- package/src/runtime/shared/tool-primitives.mjs +3 -1
- package/src/runtime/shared/tool-surface.mjs +19 -3
- package/src/runtime/shared/update-checker.mjs +54 -10
- package/src/runtime/shared/webhooks-db.mjs +405 -0
- package/src/session-runtime/channel-config-api.mjs +13 -13
- package/src/session-runtime/lifecycle-api.mjs +14 -0
- package/src/session-runtime/runtime-core.mjs +39 -20
- package/src/standalone/agent-tool.mjs +42 -11
- package/src/standalone/channel-admin.mjs +173 -121
- package/src/standalone/channel-worker.mjs +2 -2
- package/src/standalone/memory-runtime-proxy.mjs +10 -5
- package/src/tui/App.jsx +32 -10
- package/src/tui/app/channel-pickers.mjs +9 -9
- package/src/tui/app/clipboard.mjs +14 -0
- package/src/tui/app/doctor.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +17 -7
- package/src/tui/app/settings-picker.mjs +2 -2
- package/src/tui/app/transcript-window.mjs +37 -1
- package/src/tui/app/use-mouse-input.mjs +19 -6
- package/src/tui/components/ToolExecution.jsx +12 -4
- package/src/tui/display-width.mjs +10 -4
- package/src/tui/dist/index.mjs +166 -58
- package/src/tui/engine/agent-job-feed.mjs +42 -3
- package/src/tui/engine/session-api-ext.mjs +12 -12
- package/src/tui/index.jsx +12 -2
- package/src/ui/statusline-segments.mjs +12 -1
- package/src/workflows/default/WORKFLOW.md +29 -38
- package/src/workflows/solo/WORKFLOW.md +15 -20
- package/vendor/ink/build/display-width.js +5 -4
- package/src/runtime/shared/schedules-store.mjs +0 -82
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PG-backed webhook store — the single source of truth for webhook endpoint
|
|
3
|
+
* definitions (schema `webhooks`, table `webhooks.endpoints`) and the
|
|
4
|
+
* delivery dedup log (`webhooks.deliveries`).
|
|
5
|
+
*
|
|
6
|
+
* This module mirrors schedules-db.mjs: a lazy connection keyed per resolved
|
|
7
|
+
* dataDir runs idempotent DDL exactly once per process, serialized across
|
|
8
|
+
* concurrent first-boot processes on the adapter's schema-bootstrap advisory
|
|
9
|
+
* lock. It covers the file-store API surface currently in
|
|
10
|
+
* channels/lib/webhook/deliveries.mjs (loadEndpointConfig, appendDelivery,
|
|
11
|
+
* deliveryExists, _readEndpointSecret) so call sites can migrate off the
|
|
12
|
+
* per-endpoint WEBHOOK.md + deliveries.jsonl files later. No call-site
|
|
13
|
+
* changes are made in this scope.
|
|
14
|
+
*
|
|
15
|
+
* All queries fully-qualify their tables so they are correct regardless of
|
|
16
|
+
* the connection search_path.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { ensurePgInstance, withSchemaBootstrapLock } from '../memory/lib/pg/adapter.mjs';
|
|
20
|
+
import { resolvePluginData } from './plugin-paths.mjs';
|
|
21
|
+
import { readdirSync, readFileSync, rmSync, rmdirSync } from 'node:fs';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { readMarkdownDocument } from './markdown-frontmatter.mjs';
|
|
24
|
+
|
|
25
|
+
const SCHEMA = 'webhooks';
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Lazy connection + one-shot idempotent DDL (keyed per resolved dataDir).
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const _ready = new Map(); // dataDir → Promise<db>
|
|
32
|
+
|
|
33
|
+
const DDL = `
|
|
34
|
+
CREATE SCHEMA IF NOT EXISTS webhooks;
|
|
35
|
+
CREATE TABLE IF NOT EXISTS webhooks.endpoints (
|
|
36
|
+
name text PRIMARY KEY,
|
|
37
|
+
description text NOT NULL DEFAULT '',
|
|
38
|
+
channel_id text,
|
|
39
|
+
role text NOT NULL DEFAULT 'webhook-handler',
|
|
40
|
+
model text,
|
|
41
|
+
parser text,
|
|
42
|
+
secret text,
|
|
43
|
+
instructions text NOT NULL DEFAULT '',
|
|
44
|
+
enabled boolean NOT NULL DEFAULT true,
|
|
45
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
46
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
47
|
+
);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS webhooks.deliveries (
|
|
49
|
+
endpoint text NOT NULL,
|
|
50
|
+
delivery_id text NOT NULL,
|
|
51
|
+
status text NOT NULL DEFAULT 'pending',
|
|
52
|
+
event text,
|
|
53
|
+
error text,
|
|
54
|
+
headers_summary jsonb,
|
|
55
|
+
payload_preview text,
|
|
56
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
57
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
58
|
+
CONSTRAINT deliveries_endpoint_id_uniq UNIQUE (endpoint, delivery_id)
|
|
59
|
+
);
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
async function getDb(dataDir = resolvePluginData()) {
|
|
63
|
+
if (_ready.has(dataDir)) return _ready.get(dataDir);
|
|
64
|
+
const p = (async () => {
|
|
65
|
+
const { db, pool } = await ensurePgInstance(dataDir, { schema: SCHEMA });
|
|
66
|
+
// Serialize the CREATE TABLE across concurrent first-boot processes on the
|
|
67
|
+
// same cluster-global advisory lock the adapter uses for schema bootstrap,
|
|
68
|
+
// so racing first calls can't run the DDL simultaneously.
|
|
69
|
+
await withSchemaBootstrapLock(pool, () => db.exec(DDL));
|
|
70
|
+
await migrateLegacyWebhooks(db, dataDir);
|
|
71
|
+
return db;
|
|
72
|
+
})();
|
|
73
|
+
_ready.set(dataDir, p);
|
|
74
|
+
try {
|
|
75
|
+
return await p;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
_ready.delete(dataDir); // let the next call retry DDL after a transient failure
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// One-time legacy WEBHOOK.md migration (additive; runs once per dataDir on
|
|
84
|
+
// first getDb, right after DDL). Imports every `<dataDir>/webhooks/<name>/
|
|
85
|
+
// WEBHOOK.md` (+ its `secret` side file) not already present in the table (by
|
|
86
|
+
// name), then DELETES the webhooks/ directory. The legacy file reading is
|
|
87
|
+
// inlined here on purpose so this migration never depends on the file-store
|
|
88
|
+
// deliveries.mjs module (which another scope may remove). Old per-endpoint
|
|
89
|
+
// deliveries files are intentionally NOT imported — dedup history resets,
|
|
90
|
+
// which is acceptable. Partial failure keeps the directory in place and logs,
|
|
91
|
+
// so the next boot retries the un-imported entries idempotently.
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
async function migrateLegacyWebhooks(db, dataDir) {
|
|
94
|
+
const dir = join(dataDir, 'webhooks');
|
|
95
|
+
let entries;
|
|
96
|
+
try {
|
|
97
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
98
|
+
} catch {
|
|
99
|
+
return; // no legacy webhooks/ dir -> nothing to migrate
|
|
100
|
+
}
|
|
101
|
+
let imported = 0;
|
|
102
|
+
let skipped = 0;
|
|
103
|
+
const failed = [];
|
|
104
|
+
// Per-endpoint dirs safe to delete this run: those imported now, or already
|
|
105
|
+
// present in the table (a prior run imported them). Anything unrecognized —
|
|
106
|
+
// a dir with no WEBHOOK.md, a stray file, a failed import — is left in place.
|
|
107
|
+
const removable = [];
|
|
108
|
+
for (const ent of entries) {
|
|
109
|
+
if (!ent.isDirectory()) continue;
|
|
110
|
+
const name = ent.name;
|
|
111
|
+
try {
|
|
112
|
+
const { rows } = await db.query('SELECT 1 FROM webhooks.endpoints WHERE name = $1', [name]);
|
|
113
|
+
if (rows.length) { skipped++; removable.push(name); continue; }
|
|
114
|
+
let md;
|
|
115
|
+
try { md = readFileSync(join(dir, name, 'WEBHOOK.md'), 'utf8'); }
|
|
116
|
+
catch { skipped++; continue; }
|
|
117
|
+
const { frontmatter, body } = readMarkdownDocument(md);
|
|
118
|
+
let secret = null;
|
|
119
|
+
try { secret = String(readFileSync(join(dir, name, 'secret'), 'utf8')).trim() || null; }
|
|
120
|
+
catch { secret = null; }
|
|
121
|
+
const channel = String(frontmatter.channel || '').trim();
|
|
122
|
+
const enabled = frontmatter.enabled !== 'false' && frontmatter.enabled !== false;
|
|
123
|
+
await db.query(
|
|
124
|
+
`INSERT INTO webhooks.endpoints
|
|
125
|
+
(name, description, channel_id, role, model, parser, secret, instructions, enabled)
|
|
126
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
127
|
+
ON CONFLICT (name) DO NOTHING`,
|
|
128
|
+
[
|
|
129
|
+
name,
|
|
130
|
+
String(frontmatter.description || '').trim(),
|
|
131
|
+
channel || null,
|
|
132
|
+
'webhook-handler',
|
|
133
|
+
frontmatter.model ? String(frontmatter.model).trim() : null,
|
|
134
|
+
frontmatter.parser ? String(frontmatter.parser).trim() : null,
|
|
135
|
+
secret,
|
|
136
|
+
String(body || '').trim(),
|
|
137
|
+
enabled,
|
|
138
|
+
],
|
|
139
|
+
);
|
|
140
|
+
imported++;
|
|
141
|
+
removable.push(name);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
failed.push(name);
|
|
144
|
+
console.error(`[webhooks] migration failed for "${name}": ${err?.message || err}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// User chose deletion over a `.migrated` rename. Delete only the recognized
|
|
148
|
+
// per-endpoint dirs (imported now or already in the table); never blow away
|
|
149
|
+
// unimported/unrelated entries. Failed imports keep their dir for the next
|
|
150
|
+
// boot to retry (idempotent via the name check above).
|
|
151
|
+
for (const name of removable) {
|
|
152
|
+
try { rmSync(join(dir, name), { recursive: true, force: true }); }
|
|
153
|
+
catch (err) { console.error(`[webhooks] could not delete migrated dir "${name}": ${err?.message || err}`); }
|
|
154
|
+
}
|
|
155
|
+
// Remove the parent webhooks/ only once it is empty — anything unrecognized
|
|
156
|
+
// (stray files, WEBHOOK.md-less dirs, failed imports) keeps it alive.
|
|
157
|
+
let parentGone = false;
|
|
158
|
+
try {
|
|
159
|
+
if (readdirSync(dir).length === 0) { rmdirSync(dir); parentGone = true; }
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error(`[webhooks] could not remove empty webhooks/: ${err?.message || err}`);
|
|
162
|
+
}
|
|
163
|
+
const tail = failed.length ? `${failed.length} failed (${failed.join(', ')}); ` : '';
|
|
164
|
+
console.error(`[webhooks] migrated ${imported} legacy webhook(s) (${skipped} skipped); ${tail}${parentGone ? 'removed webhooks/' : 'kept webhooks/ (non-empty)'}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Row <-> def mapping
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
const ENDPOINT_COLS = 'name, description, channel_id, role, model, parser, secret, instructions, enabled, created_at, updated_at';
|
|
172
|
+
|
|
173
|
+
function rowToEndpoint(row) {
|
|
174
|
+
if (!row) return null;
|
|
175
|
+
return {
|
|
176
|
+
name: row.name,
|
|
177
|
+
description: row.description,
|
|
178
|
+
channelId: row.channel_id,
|
|
179
|
+
role: row.role,
|
|
180
|
+
model: row.model,
|
|
181
|
+
parser: row.parser,
|
|
182
|
+
// Never project the plaintext secret through list/load config paths;
|
|
183
|
+
// callers get a presence flag and must fetch the value via
|
|
184
|
+
// readEndpointSecret (the single, explicit secret-read path).
|
|
185
|
+
secretSet: Boolean(row.secret && String(row.secret).trim()),
|
|
186
|
+
instructions: row.instructions,
|
|
187
|
+
enabled: row.enabled,
|
|
188
|
+
createdAt: row.created_at,
|
|
189
|
+
updatedAt: row.updated_at,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const DELIVERY_COLS = 'endpoint, delivery_id, status, event, error, headers_summary, payload_preview, created_at, updated_at';
|
|
194
|
+
|
|
195
|
+
function rowToDelivery(row) {
|
|
196
|
+
if (!row) return null;
|
|
197
|
+
return {
|
|
198
|
+
endpoint: row.endpoint,
|
|
199
|
+
deliveryId: row.delivery_id,
|
|
200
|
+
status: row.status,
|
|
201
|
+
event: row.event,
|
|
202
|
+
error: row.error,
|
|
203
|
+
headersSummary: row.headers_summary,
|
|
204
|
+
payloadPreview: row.payload_preview,
|
|
205
|
+
createdAt: row.created_at,
|
|
206
|
+
updatedAt: row.updated_at,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// Endpoint definitions
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
export async function listEndpoints({ dataDir } = {}) {
|
|
215
|
+
const db = await getDb(dataDir);
|
|
216
|
+
const { rows } = await db.query(`SELECT ${ENDPOINT_COLS} FROM webhooks.endpoints ORDER BY name`);
|
|
217
|
+
return rows.map(rowToEndpoint);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Full endpoint config by name (replaces loadEndpointConfig's file read):
|
|
222
|
+
* the frontmatter fields as one object, or null when absent.
|
|
223
|
+
*/
|
|
224
|
+
export async function loadEndpointConfig(name, { dataDir } = {}) {
|
|
225
|
+
if (!name) return null;
|
|
226
|
+
const db = await getDb(dataDir);
|
|
227
|
+
const { rows } = await db.query(`SELECT ${ENDPOINT_COLS} FROM webhooks.endpoints WHERE name = $1`, [name]);
|
|
228
|
+
return rowToEndpoint(rows[0]);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Per-endpoint HMAC secret only (replaces _readEndpointSecret's side-file
|
|
233
|
+
* read). Returns the trimmed secret or null.
|
|
234
|
+
*/
|
|
235
|
+
export async function readEndpointSecret(name, { dataDir } = {}) {
|
|
236
|
+
if (!name) return null;
|
|
237
|
+
const db = await getDb(dataDir);
|
|
238
|
+
const { rows } = await db.query('SELECT secret FROM webhooks.endpoints WHERE name = $1', [name]);
|
|
239
|
+
const s = rows[0]?.secret;
|
|
240
|
+
return s ? String(s).trim() || null : null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Insert-or-replace an endpoint by name. On re-upsert every column is reset
|
|
245
|
+
* from the incoming def (enabled and other stale fields included) so a
|
|
246
|
+
* re-registration is a clean redefinition, mirroring upsertSchedule.
|
|
247
|
+
*/
|
|
248
|
+
export async function upsertEndpoint(def, { dataDir } = {}) {
|
|
249
|
+
if (!def || !def.name) throw new Error('upsertEndpoint: def.name is required');
|
|
250
|
+
const db = await getDb(dataDir);
|
|
251
|
+
const params = [
|
|
252
|
+
def.name,
|
|
253
|
+
def.description ?? '',
|
|
254
|
+
def.channelId ?? null,
|
|
255
|
+
def.role ?? 'webhook-handler',
|
|
256
|
+
def.model ?? null,
|
|
257
|
+
def.parser ?? null,
|
|
258
|
+
def.secret ?? null,
|
|
259
|
+
def.instructions ?? '',
|
|
260
|
+
def.enabled ?? true,
|
|
261
|
+
];
|
|
262
|
+
const { rows } = await db.query(
|
|
263
|
+
`INSERT INTO webhooks.endpoints
|
|
264
|
+
(name, description, channel_id, role, model, parser, secret, instructions, enabled)
|
|
265
|
+
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
266
|
+
ON CONFLICT (name) DO UPDATE SET
|
|
267
|
+
description = EXCLUDED.description,
|
|
268
|
+
channel_id = EXCLUDED.channel_id,
|
|
269
|
+
role = EXCLUDED.role,
|
|
270
|
+
model = EXCLUDED.model,
|
|
271
|
+
parser = EXCLUDED.parser,
|
|
272
|
+
secret = EXCLUDED.secret,
|
|
273
|
+
instructions = EXCLUDED.instructions,
|
|
274
|
+
enabled = EXCLUDED.enabled,
|
|
275
|
+
updated_at = now()
|
|
276
|
+
RETURNING ${ENDPOINT_COLS}`,
|
|
277
|
+
params,
|
|
278
|
+
);
|
|
279
|
+
return rowToEndpoint(rows[0]);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function setEndpointEnabled(name, enabled, { dataDir } = {}) {
|
|
283
|
+
const db = await getDb(dataDir);
|
|
284
|
+
const { rows } = await db.query(
|
|
285
|
+
`UPDATE webhooks.endpoints SET enabled = $2, updated_at = now() WHERE name = $1 RETURNING ${ENDPOINT_COLS}`,
|
|
286
|
+
[name, !!enabled],
|
|
287
|
+
);
|
|
288
|
+
return rowToEndpoint(rows[0]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function deleteEndpoint(name, { dataDir } = {}) {
|
|
292
|
+
const db = await getDb(dataDir);
|
|
293
|
+
// Atomically drop the endpoint AND its delivery log in one statement: a
|
|
294
|
+
// data-modifying CTE deletes the deliveries, the outer DELETE removes the
|
|
295
|
+
// endpoint row, so no orphan deliveries can survive a removed endpoint.
|
|
296
|
+
const { rowCount } = await db.query(
|
|
297
|
+
`WITH del AS (DELETE FROM webhooks.deliveries WHERE endpoint = $1 RETURNING 1)
|
|
298
|
+
DELETE FROM webhooks.endpoints WHERE name = $1`,
|
|
299
|
+
[name],
|
|
300
|
+
);
|
|
301
|
+
return rowCount > 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// Delivery dedup log
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Atomically claim a delivery id for an endpoint. INSERT ... ON CONFLICT DO
|
|
310
|
+
* NOTHING makes the claim a single indivisible step, so two concurrent POSTs
|
|
311
|
+
* of the same id cannot both win: exactly one insert affects a row.
|
|
312
|
+
* Returns { claimed: true, row } for the winner and { claimed: false,
|
|
313
|
+
* duplicate: true, row } for the loser (row = the existing claim).
|
|
314
|
+
*/
|
|
315
|
+
export async function claimDelivery(endpoint, deliveryId, fields = {}, { dataDir } = {}) {
|
|
316
|
+
if (!endpoint || !deliveryId) throw new Error('claimDelivery: endpoint and deliveryId are required');
|
|
317
|
+
const db = await getDb(dataDir);
|
|
318
|
+
const params = [
|
|
319
|
+
endpoint,
|
|
320
|
+
deliveryId,
|
|
321
|
+
fields.status ?? 'pending',
|
|
322
|
+
fields.event ?? null,
|
|
323
|
+
fields.headersSummary ?? null,
|
|
324
|
+
fields.payloadPreview ?? null,
|
|
325
|
+
];
|
|
326
|
+
const { rows } = await db.query(
|
|
327
|
+
`INSERT INTO webhooks.deliveries
|
|
328
|
+
(endpoint, delivery_id, status, event, headers_summary, payload_preview)
|
|
329
|
+
VALUES ($1,$2,$3,$4,$5,$6)
|
|
330
|
+
ON CONFLICT (endpoint, delivery_id) DO NOTHING
|
|
331
|
+
RETURNING ${DELIVERY_COLS}`,
|
|
332
|
+
params,
|
|
333
|
+
);
|
|
334
|
+
if (rows[0]) return { claimed: true, duplicate: false, row: rowToDelivery(rows[0]) };
|
|
335
|
+
// Lost the race (or a prior claim exists): return the existing row.
|
|
336
|
+
const { rows: existing } = await db.query(
|
|
337
|
+
`SELECT ${DELIVERY_COLS} FROM webhooks.deliveries WHERE endpoint = $1 AND delivery_id = $2`,
|
|
338
|
+
[endpoint, deliveryId],
|
|
339
|
+
);
|
|
340
|
+
return { claimed: false, duplicate: true, row: rowToDelivery(existing[0]) };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* True when a delivery id has already been recorded for the endpoint (any
|
|
345
|
+
* status). Callers wanting terminal-vs-inflight semantics inspect the row via
|
|
346
|
+
* getDelivery; claimDelivery is the race-safe gate.
|
|
347
|
+
*/
|
|
348
|
+
export async function deliveryExists(endpoint, deliveryId, { dataDir } = {}) {
|
|
349
|
+
if (!endpoint || !deliveryId) return false;
|
|
350
|
+
const db = await getDb(dataDir);
|
|
351
|
+
const { rows } = await db.query(
|
|
352
|
+
'SELECT 1 FROM webhooks.deliveries WHERE endpoint = $1 AND delivery_id = $2',
|
|
353
|
+
[endpoint, deliveryId],
|
|
354
|
+
);
|
|
355
|
+
return rows.length > 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export async function getDelivery(endpoint, deliveryId, { dataDir } = {}) {
|
|
359
|
+
const db = await getDb(dataDir);
|
|
360
|
+
const { rows } = await db.query(
|
|
361
|
+
`SELECT ${DELIVERY_COLS} FROM webhooks.deliveries WHERE endpoint = $1 AND delivery_id = $2`,
|
|
362
|
+
[endpoint, deliveryId],
|
|
363
|
+
);
|
|
364
|
+
return rowToDelivery(rows[0]);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Update the status (and optional event/error) of an existing delivery,
|
|
369
|
+
* keyed by (endpoint, delivery_id). Returns the updated row or null.
|
|
370
|
+
*/
|
|
371
|
+
export async function updateDeliveryStatus(endpoint, deliveryId, status, fields = {}, { dataDir } = {}) {
|
|
372
|
+
if (!endpoint || !deliveryId) throw new Error('updateDeliveryStatus: endpoint and deliveryId are required');
|
|
373
|
+
const db = await getDb(dataDir);
|
|
374
|
+
const { rows } = await db.query(
|
|
375
|
+
`UPDATE webhooks.deliveries
|
|
376
|
+
SET status = $3,
|
|
377
|
+
event = COALESCE($4, event),
|
|
378
|
+
error = COALESCE($5, error),
|
|
379
|
+
updated_at = now()
|
|
380
|
+
WHERE endpoint = $1 AND delivery_id = $2
|
|
381
|
+
RETURNING ${DELIVERY_COLS}`,
|
|
382
|
+
[endpoint, deliveryId, status, fields.event ?? null, fields.error ?? null],
|
|
383
|
+
);
|
|
384
|
+
return rowToDelivery(rows[0]);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Append a delivery record (claim-or-update convenience covering the old
|
|
389
|
+
* appendDelivery). First write for an id claims it; a later write with the
|
|
390
|
+
* same id updates its status/fields latest-wins.
|
|
391
|
+
*
|
|
392
|
+
* Returns the claim outcome — { claimed, duplicate, row } — NOT a bare row,
|
|
393
|
+
* so a pre-dispatch caller can detect a concurrent duplicate (claimed:false,
|
|
394
|
+
* duplicate:true) and skip re-dispatching an in-flight delivery. `row` is the
|
|
395
|
+
* freshly claimed row when claimed, else the existing row after the
|
|
396
|
+
* latest-wins status/fields update.
|
|
397
|
+
*/
|
|
398
|
+
export async function appendDelivery(endpoint, entry = {}, { dataDir } = {}) {
|
|
399
|
+
const deliveryId = entry.deliveryId ?? entry.id;
|
|
400
|
+
if (!endpoint || !deliveryId) throw new Error('appendDelivery: endpoint and deliveryId are required');
|
|
401
|
+
const claim = await claimDelivery(endpoint, deliveryId, entry, { dataDir });
|
|
402
|
+
if (claim.claimed) return claim;
|
|
403
|
+
const row = await updateDeliveryStatus(endpoint, deliveryId, entry.status ?? claim.row?.status, entry, { dataDir });
|
|
404
|
+
return { claimed: false, duplicate: true, row };
|
|
405
|
+
}
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
// worker handle, soft reload).
|
|
17
17
|
export function createChannelConfigApi({ flushBackendSave, channels, reloadChannelsSoon }) {
|
|
18
18
|
return {
|
|
19
|
-
getChannelSetup() {
|
|
19
|
+
async getChannelSetup() {
|
|
20
20
|
// Flush a pending debounced backend switch first so setup readers
|
|
21
21
|
// (Settings → Channel Setting, remote toggles) never observe the
|
|
22
22
|
// previous backend during the 150ms debounce window.
|
|
@@ -36,33 +36,33 @@ export function createChannelConfigApi({ flushBackendSave, channels, reloadChann
|
|
|
36
36
|
reloadChannelsSoon();
|
|
37
37
|
return result;
|
|
38
38
|
},
|
|
39
|
-
saveSchedule(entry) {
|
|
40
|
-
const result = saveSchedule(entry);
|
|
39
|
+
async saveSchedule(entry) {
|
|
40
|
+
const result = await saveSchedule(entry);
|
|
41
41
|
reloadChannelsSoon();
|
|
42
42
|
return result;
|
|
43
43
|
},
|
|
44
|
-
deleteSchedule(name) {
|
|
45
|
-
const result = deleteSchedule(name);
|
|
44
|
+
async deleteSchedule(name) {
|
|
45
|
+
const result = await deleteSchedule(name);
|
|
46
46
|
reloadChannelsSoon();
|
|
47
47
|
return result;
|
|
48
48
|
},
|
|
49
|
-
setScheduleEnabled(name, enabled) {
|
|
50
|
-
const result = setScheduleEnabled(name, enabled);
|
|
49
|
+
async setScheduleEnabled(name, enabled) {
|
|
50
|
+
const result = await setScheduleEnabled(name, enabled);
|
|
51
51
|
reloadChannelsSoon();
|
|
52
52
|
return result;
|
|
53
53
|
},
|
|
54
|
-
saveWebhook(entry) {
|
|
55
|
-
const result = saveWebhook(entry);
|
|
54
|
+
async saveWebhook(entry) {
|
|
55
|
+
const result = await saveWebhook(entry);
|
|
56
56
|
reloadChannelsSoon();
|
|
57
57
|
return result;
|
|
58
58
|
},
|
|
59
|
-
deleteWebhook(name) {
|
|
60
|
-
const result = deleteWebhook(name);
|
|
59
|
+
async deleteWebhook(name) {
|
|
60
|
+
const result = await deleteWebhook(name);
|
|
61
61
|
reloadChannelsSoon();
|
|
62
62
|
return result;
|
|
63
63
|
},
|
|
64
|
-
setWebhookEnabled(name, enabled) {
|
|
65
|
-
const result = setWebhookEnabled(name, enabled);
|
|
64
|
+
async setWebhookEnabled(name, enabled) {
|
|
65
|
+
const result = await setWebhookEnabled(name, enabled);
|
|
66
66
|
reloadChannelsSoon();
|
|
67
67
|
return result;
|
|
68
68
|
},
|
|
@@ -29,11 +29,21 @@ export function createLifecycleApi(deps) {
|
|
|
29
29
|
invalidateContextStatusCache, invalidatePreSessionToolSurface,
|
|
30
30
|
applyResolvedCwd, resolveRoute, applyDeferredToolSurface, standaloneTools,
|
|
31
31
|
pushTranscriptRebind,
|
|
32
|
+
flushPendingUpdate,
|
|
32
33
|
} = deps;
|
|
33
34
|
return {
|
|
34
35
|
async close(reason = 'cli-exit', options = {}) {
|
|
35
36
|
const detach = options?.detach === true || options?.wait === false || options?.waitForExit === false;
|
|
36
37
|
setCloseRequested(true);
|
|
38
|
+
// Deferred self-update: arm-at-boot, install-on-quit. Only a real
|
|
39
|
+
// process exit (not a /clear session reset) triggers the npm install;
|
|
40
|
+
// the spawn itself is deferred to AFTER teardown below so npm overwrites
|
|
41
|
+
// the install once this process has released its file handles.
|
|
42
|
+
const isProcessExit = /exit|quit|shutdown|sighup|sigint|sigterm/.test(String(reason || '').toLowerCase());
|
|
43
|
+
const flushUpdateOnExit = () => {
|
|
44
|
+
if (!isProcessExit) return;
|
|
45
|
+
try { flushPendingUpdate?.(); } catch { /* exit must never wedge on the update spawn */ }
|
|
46
|
+
};
|
|
37
47
|
// SessionEnd: bridge teardown to the standard hook bus. reason mapped to
|
|
38
48
|
// standard values ('clear'/'exit' where applicable, else 'other'). Short
|
|
39
49
|
// await guard so a slow hook cannot wedge teardown; best-effort.
|
|
@@ -140,6 +150,7 @@ export function createLifecycleApi(deps) {
|
|
|
140
150
|
for (const stop of [mcpStop, openaiWsStop, patchStop]) {
|
|
141
151
|
Promise.resolve(stop).catch(() => {});
|
|
142
152
|
}
|
|
153
|
+
flushUpdateOnExit();
|
|
143
154
|
return ok;
|
|
144
155
|
}
|
|
145
156
|
await Promise.allSettled([
|
|
@@ -151,6 +162,9 @@ export function createLifecycleApi(deps) {
|
|
|
151
162
|
withTeardownDeadline(shellJobsStop, 1500, false),
|
|
152
163
|
withTeardownDeadline(bashSessionsStop, 1500, false),
|
|
153
164
|
]);
|
|
165
|
+
// After teardown: file handles (channels/mcp/memory/shell/patch) are
|
|
166
|
+
// released, so npm can now overwrite the install without TAR_ENTRY_ERROR.
|
|
167
|
+
flushUpdateOnExit();
|
|
154
168
|
return ok;
|
|
155
169
|
},
|
|
156
170
|
abort(reason = 'cli-abort') {
|
|
@@ -15,7 +15,7 @@ import { writeLastSessionCwd } from '../runtime/shared/user-cwd.mjs';
|
|
|
15
15
|
import { cancelBackgroundTasks } from '../runtime/shared/background-tasks.mjs';
|
|
16
16
|
import { createTranscriptWriter } from '../runtime/shared/transcript-writer.mjs';
|
|
17
17
|
import { mixdogHome } from '../runtime/shared/plugin-paths.mjs';
|
|
18
|
-
import { checkLatestVersion, runGlobalUpdate, localPackageVersion } from '../runtime/shared/update-checker.mjs';
|
|
18
|
+
import { checkLatestVersion, runGlobalUpdate, localPackageVersion, isDevInstall } from '../runtime/shared/update-checker.mjs';
|
|
19
19
|
import {
|
|
20
20
|
modelVisibleToolCompletionMessage,
|
|
21
21
|
shouldPersistModelVisibleToolCompletion,
|
|
@@ -634,6 +634,12 @@ export async function createMixdogSessionRuntime({
|
|
|
634
634
|
};
|
|
635
635
|
// phase: 'idle' | 'checking' | 'installing' | 'installed' | 'failed'
|
|
636
636
|
let updateProcessState = { phase: 'idle', version: null, error: null };
|
|
637
|
+
// Boot detects an available update but DEFERS the actual npm install to the
|
|
638
|
+
// graceful shutdown path (flushPendingUpdate, invoked from close()). Running
|
|
639
|
+
// `npm install -g` while the process is live overwrites the very .mjs files
|
|
640
|
+
// node has loaded → Windows TAR_ENTRY_ERROR file-locks. Installing on quit
|
|
641
|
+
// lets the parent release those handles first.
|
|
642
|
+
let pendingUpdateInstall = false;
|
|
637
643
|
|
|
638
644
|
function autoUpdateEnabled() {
|
|
639
645
|
return config?.update?.auto !== false;
|
|
@@ -662,6 +668,9 @@ export async function createMixdogSessionRuntime({
|
|
|
662
668
|
if (updateProcessState.phase === 'installing') {
|
|
663
669
|
return { ...updateProcessState, alreadyInstalling: true, error: 'update already in progress' };
|
|
664
670
|
}
|
|
671
|
+
// A manual install consumes the boot-armed deferred install so quitting
|
|
672
|
+
// afterwards can't spawn a second redundant `npm install -g`.
|
|
673
|
+
pendingUpdateInstall = false;
|
|
665
674
|
updateProcessState = { phase: 'installing', version: null, error: null };
|
|
666
675
|
try {
|
|
667
676
|
const result = await runGlobalUpdate();
|
|
@@ -678,36 +687,45 @@ export async function createMixdogSessionRuntime({
|
|
|
678
687
|
|
|
679
688
|
// Non-blocking boot hook: fires after the runtime object below is fully
|
|
680
689
|
// constructed (setTimeout(0) defers past the synchronous return), so a
|
|
681
|
-
// slow/hanging registry request
|
|
682
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
690
|
+
// slow/hanging registry request can never delay session boot. The check
|
|
691
|
+
// ALWAYS runs (populates updateCheckState for the maintenance picker), but
|
|
692
|
+
// the boot hook only DETECTS + arms a deferred install — the actual
|
|
693
|
+
// runGlobalUpdate now happens on graceful shutdown (flushPendingUpdate via
|
|
694
|
+
// close()) so npm never overwrites files the live process holds.
|
|
686
695
|
// force:true — always hit the registry at boot (the 24h disk cache went
|
|
687
696
|
// stale-visible: it kept reporting an older "latest" than the installed
|
|
688
697
|
// version). checkLatestVersion() still falls back to the cache offline.
|
|
698
|
+
// isDevInstall() gate: a git checkout / clone (or non-node_modules install)
|
|
699
|
+
// must never self-update — `npm install -g` would fight the working tree.
|
|
689
700
|
const updateBootTimer = setTimeout(() => {
|
|
690
701
|
void (async () => {
|
|
691
702
|
await checkForUpdateInternal({ force: true });
|
|
692
|
-
if (autoUpdateEnabled() && updateCheckState.updateAvailable) {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
// meta.kind 'update-notice' to a transcript
|
|
696
|
-
// model-visible message).
|
|
697
|
-
//
|
|
698
|
-
|
|
699
|
-
if (result?.phase === 'installed') {
|
|
700
|
-
const v = result.version && result.version !== 'unknown'
|
|
701
|
-
? `v${result.version}` : (updateCheckState.latestVersion ? `v${updateCheckState.latestVersion}` : 'latest');
|
|
702
|
-
emitRuntimeNotification(`mixdog ${v} installed — restart to apply.`, { kind: 'update-notice', tone: 'info' });
|
|
703
|
-
} else if (result?.phase === 'failed') {
|
|
704
|
-
emitRuntimeNotification(`mixdog auto-update failed: ${result.error || 'unknown error'}`, { kind: 'update-notice', tone: 'warn' });
|
|
705
|
-
}
|
|
703
|
+
if (autoUpdateEnabled() && !isDevInstall() && updateCheckState.updateAvailable) {
|
|
704
|
+
pendingUpdateInstall = true;
|
|
705
|
+
const v = updateCheckState.latestVersion ? `v${updateCheckState.latestVersion}` : 'latest';
|
|
706
|
+
// UI-only notice (TUI maps meta.kind 'update-notice' to a transcript
|
|
707
|
+
// notice, never a model-visible message). tone 'info': non-urgent,
|
|
708
|
+
// the install fires quietly on quit.
|
|
709
|
+
emitRuntimeNotification(`mixdog ${v} available — installs when you quit.`, { kind: 'update-notice', tone: 'info' });
|
|
706
710
|
}
|
|
707
711
|
})().catch(() => {});
|
|
708
712
|
}, 0);
|
|
709
713
|
updateBootTimer.unref?.();
|
|
710
714
|
|
|
715
|
+
// Invoked from the graceful shutdown path (lifecycle close() on exit/quit).
|
|
716
|
+
// Spawns the deferred npm install fire-and-forget: runGlobalUpdate() detaches
|
|
717
|
+
// (POSIX) / unrefs its child, so we neither await it nor block teardown —
|
|
718
|
+
// the parent exits, releasing file handles, while npm installs in the hidden
|
|
719
|
+
// background child. No-op unless an update was armed at boot.
|
|
720
|
+
function flushPendingUpdate() {
|
|
721
|
+
if (!pendingUpdateInstall) return;
|
|
722
|
+
pendingUpdateInstall = false;
|
|
723
|
+
// Re-check at flush time: the user may have turned update.auto off after
|
|
724
|
+
// the boot notice armed the install — respect the current setting.
|
|
725
|
+
if (!autoUpdateEnabled()) return;
|
|
726
|
+
try { void runGlobalUpdate(); } catch { /* best-effort: exit must not wedge */ }
|
|
727
|
+
}
|
|
728
|
+
|
|
711
729
|
function emitRuntimeNotification(content, meta = {}) {
|
|
712
730
|
const text = String(content || '').trim();
|
|
713
731
|
if (!text) return false;
|
|
@@ -1985,6 +2003,7 @@ export async function createMixdogSessionRuntime({
|
|
|
1985
2003
|
resolveRoute,
|
|
1986
2004
|
applyDeferredToolSurface,
|
|
1987
2005
|
standaloneTools,
|
|
2006
|
+
flushPendingUpdate,
|
|
1988
2007
|
});
|
|
1989
2008
|
const resourceApi = createResourceApi({
|
|
1990
2009
|
getConfig: () => config,
|
|
@@ -1587,17 +1587,48 @@ export function createStandaloneAgent({ cfgMod, reg, mgr, dataDir, cwd: defaultC
|
|
|
1587
1587
|
if (!fallbackTag || fallbackTag.startsWith('sess_') || !isDeadTarget) throw err;
|
|
1588
1588
|
const prompt = clean(args.message || args.prompt);
|
|
1589
1589
|
if (!prompt) throw err;
|
|
1590
|
-
// Inherit the dead session's agent/cwd
|
|
1591
|
-
//
|
|
1592
|
-
//
|
|
1593
|
-
//
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
//
|
|
1598
|
-
// the
|
|
1599
|
-
|
|
1600
|
-
|
|
1590
|
+
// Inherit the dead session's agent/cwd so the auto-respawn doesn't
|
|
1591
|
+
// die on "agent spawn: agent is required" when Lead's send (correctly)
|
|
1592
|
+
// omitted them. Read BEFORE forgetTag wipes the registry entries.
|
|
1593
|
+
//
|
|
1594
|
+
// Root cause of the surfaced "target not found" errors: when a tag is
|
|
1595
|
+
// FULLY reaped (session gone past the 5m grace window), the in-memory
|
|
1596
|
+
// tagAgents/tagCwds maps are cleared, so inheritedAgent came back empty
|
|
1597
|
+
// and this path re-threw instead of respawning. Recover the agent/cwd
|
|
1598
|
+
// from the persisted worker row, then fall back to the default `worker`
|
|
1599
|
+
// agent so an unknown-but-plausible tag still cold-respawns. The guards
|
|
1600
|
+
// above (empty tag / raw sess_ id / non-dead error) keep plainly invalid
|
|
1601
|
+
// targets erroring.
|
|
1602
|
+
// readWorkerRows is already scoped to THIS terminal (clientHostPid,
|
|
1603
|
+
// via rowMatchesContext), so inheritedRow — and every mutation below
|
|
1604
|
+
// keyed on its sessionId — never touches another terminal's same-tag
|
|
1605
|
+
// worker row.
|
|
1606
|
+
let inheritedRow = null;
|
|
1607
|
+
try {
|
|
1608
|
+
inheritedRow = readWorkerRows(scopedContext).find((row) => clean(row.tag) === fallbackTag) || null;
|
|
1609
|
+
} catch { inheritedRow = null; }
|
|
1610
|
+
// Gate the respawn on evidence the tag previously existed IN THIS
|
|
1611
|
+
// terminal: a persisted, clientHostPid-scoped worker row. The
|
|
1612
|
+
// in-memory tags/tagAgents/tagCwds maps are NOT reliable evidence —
|
|
1613
|
+
// closeAll() calls refreshTagsFromSessions() with NO context
|
|
1614
|
+
// (see ~L1501), which populates them from EVERY terminal's rows
|
|
1615
|
+
// (readWorkerRows({}) — rowMatchesContext returns true with no pid),
|
|
1616
|
+
// so they can hold a peer terminal's tag. A typo'd / never-existed
|
|
1617
|
+
// tag has no scoped row and must keep the original "not found" error.
|
|
1618
|
+
if (!inheritedRow) throw err;
|
|
1619
|
+
const inheritedSessionId = clean(inheritedRow.sessionId);
|
|
1620
|
+
const inheritedAgent = clean(args.agent) || clean(inheritedRow.agent) || 'worker';
|
|
1621
|
+
const inheritedCwd = clean(args.cwd) || clean(inheritedRow.cwd) || clean(callerCwd);
|
|
1622
|
+
// Drop this terminal's in-memory trace and remove ONLY the persisted
|
|
1623
|
+
// row matching inheritedRow.sessionId. Do NOT call forgetTag here: it
|
|
1624
|
+
// does a tag-wide removeWorkerRow({tag,sessionId}) (L556) whose OR
|
|
1625
|
+
// match (L395) would delete peer terminals' same-tag rows. The map
|
|
1626
|
+
// deletes are guarded on the tag pointing at OUR sessionId so a peer
|
|
1627
|
+
// cache entry (see above) is left intact (it rebuilds from rows).
|
|
1628
|
+
if (tags.get(fallbackTag) === inheritedSessionId) {
|
|
1629
|
+
try { tags.delete(fallbackTag); tagAgents.delete(fallbackTag); tagCwds.delete(fallbackTag); } catch {}
|
|
1630
|
+
}
|
|
1631
|
+
if (inheritedSessionId) { try { removeWorkerRow({ sessionId: inheritedSessionId }); } catch {} }
|
|
1601
1632
|
const spawnArgs = {
|
|
1602
1633
|
...args,
|
|
1603
1634
|
type: 'spawn',
|