@adhdev/daemon-core 1.0.18-rc.8 → 1.0.18-rc.9
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/dist/commands/windows-atomic-upgrade.d.ts +4 -0
- package/dist/index.js +115 -42
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +115 -42
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/commands/upgrade-helper.ts +28 -14
- package/src/commands/windows-atomic-upgrade.ts +61 -18
- package/src/mesh/mesh-queue-assignment.ts +126 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "1.0.18-rc.
|
|
3
|
+
"version": "1.0.18-rc.9",
|
|
4
4
|
"description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"author": "vilmire",
|
|
48
48
|
"license": "AGPL-3.0-or-later",
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@adhdev/mesh-shared": "1.0.18-rc.
|
|
51
|
-
"@adhdev/session-host-core": "1.0.18-rc.
|
|
50
|
+
"@adhdev/mesh-shared": "1.0.18-rc.9",
|
|
51
|
+
"@adhdev/session-host-core": "1.0.18-rc.9",
|
|
52
52
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
53
53
|
"ajv": "^8.20.0",
|
|
54
54
|
"ajv-formats": "^3.0.1",
|
|
@@ -560,22 +560,36 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
|
|
|
560
560
|
);
|
|
561
561
|
}
|
|
562
562
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
targetVersion: payload.targetVersion,
|
|
567
|
-
portableNode,
|
|
568
|
-
excludePids: upgradePids,
|
|
569
|
-
hooks: createDefaultWindowsAtomicHooks({
|
|
563
|
+
try {
|
|
564
|
+
await performWindowsAtomicUpgrade({
|
|
565
|
+
layout: windowsInstallerLayout,
|
|
570
566
|
packageName: payload.packageName,
|
|
571
567
|
targetVersion: payload.targetVersion,
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
568
|
+
portableNode,
|
|
569
|
+
excludePids: upgradePids,
|
|
570
|
+
hooks: createDefaultWindowsAtomicHooks({
|
|
571
|
+
packageName: payload.packageName,
|
|
572
|
+
targetVersion: payload.targetVersion,
|
|
573
|
+
npmCliPath,
|
|
574
|
+
restartArgv,
|
|
575
|
+
cwd: payload.cwd || process.cwd(),
|
|
576
|
+
env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== UPGRADE_HELPER_ENV)),
|
|
577
|
+
log: appendUpgradeLog,
|
|
578
|
+
}),
|
|
579
|
+
});
|
|
580
|
+
} catch (error: any) {
|
|
581
|
+
// performWindowsAtomicUpgrade already rolled the pointer/shims back to the
|
|
582
|
+
// prior version before rethrowing. Without a durable notice that rollback
|
|
583
|
+
// was silent — the daemon simply kept running the old version (the rc.6
|
|
584
|
+
// stuck-upgrade defect). Leave an actionable last-error file naming the
|
|
585
|
+
// target that failed its health/version gate.
|
|
586
|
+
emitUpgradeFailureNotice([
|
|
587
|
+
`adhdev ${payload.packageName}@${payload.targetVersion} upgrade failed and was rolled back: ${error?.message || String(error)}`,
|
|
588
|
+
`Previous version preserved (active prefix: ${windowsInstallerLayout.activePrefix}).`,
|
|
589
|
+
'See daemon-upgrade.log for the full install/health trace. The next daemon start will retry.',
|
|
590
|
+
]);
|
|
591
|
+
throw error;
|
|
592
|
+
}
|
|
579
593
|
try { fs.unlinkSync(getUpgradeFailureNoticePath()); } catch { /* no previous failure notice */ }
|
|
580
594
|
appendUpgradeLog('Installer-managed Windows atomic upgrade completed');
|
|
581
595
|
return;
|
|
@@ -8,6 +8,44 @@ const POINTER_NAME = '.adhdev-current';
|
|
|
8
8
|
const STABLE_FILES = [POINTER_NAME, 'adhdev.cmd', 'adhdev.ps1', 'adhdev'] as const;
|
|
9
9
|
const DEFAULT_HEALTH_PORT = 19222;
|
|
10
10
|
|
|
11
|
+
function fetchLocalJson(port: number, pathname: string): Promise<{ ok: boolean; body: string }> {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const req = http.get(`http://127.0.0.1:${port}${pathname}`, { timeout: 1500 }, (res) => {
|
|
14
|
+
let body = '';
|
|
15
|
+
res.setEncoding('utf8');
|
|
16
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
17
|
+
res.on('end', () => resolve({ ok: res.statusCode === 200, body }));
|
|
18
|
+
});
|
|
19
|
+
req.on('timeout', () => req.destroy());
|
|
20
|
+
req.on('error', () => resolve({ ok: false, body: '' }));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Liveness + pid identity: GET /health returns {ok, pid, wsPath, port}.
|
|
25
|
+
async function fetchLocalHealth(port: number): Promise<{ ok: boolean; pid?: number }> {
|
|
26
|
+
const { ok, body } = await fetchLocalJson(port, '/health');
|
|
27
|
+
if (!ok) return { ok: false };
|
|
28
|
+
try {
|
|
29
|
+
const pid = Number(JSON.parse(body)?.pid);
|
|
30
|
+
return { ok: true, pid: Number.isFinite(pid) ? pid : undefined };
|
|
31
|
+
} catch {
|
|
32
|
+
return { ok: false };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Running version: GET /api/v1/status exposes it at payload.status.version.
|
|
37
|
+
// /health carries no version, so the upgrade version gate must read this.
|
|
38
|
+
async function fetchLocalStatusVersion(port: number): Promise<string | undefined> {
|
|
39
|
+
const { ok, body } = await fetchLocalJson(port, '/api/v1/status');
|
|
40
|
+
if (!ok) return undefined;
|
|
41
|
+
try {
|
|
42
|
+
const version = (JSON.parse(body) as { status?: { version?: unknown } })?.status?.version;
|
|
43
|
+
return typeof version === 'string' ? version : undefined;
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
11
49
|
// Markers that identify a Node process as ADHDev-owned when its command line
|
|
12
50
|
// also lives under a versioned install prefix. This deliberately excludes
|
|
13
51
|
// arbitrary user scripts that happen to be located under ~/.adhdev.
|
|
@@ -359,7 +397,13 @@ export function createDefaultWindowsAtomicHooks(options: {
|
|
|
359
397
|
cwd: string;
|
|
360
398
|
env: NodeJS.ProcessEnv;
|
|
361
399
|
log: (message: string) => void;
|
|
400
|
+
/** Loopback IPC port to probe for health/version. Defaults to the daemon's local IPC port. */
|
|
401
|
+
healthPort?: number;
|
|
402
|
+
/** How long to poll for the replacement daemon to report the target version. */
|
|
403
|
+
healthTimeoutMs?: number;
|
|
362
404
|
}): WindowsAtomicUpgradeHooks {
|
|
405
|
+
const healthPort = options.healthPort ?? DEFAULT_HEALTH_PORT;
|
|
406
|
+
const healthTimeoutMs = options.healthTimeoutMs ?? 30000;
|
|
363
407
|
return {
|
|
364
408
|
install: (stagedPrefix, portableNode) => {
|
|
365
409
|
const env: NodeJS.ProcessEnv = {
|
|
@@ -370,7 +414,7 @@ export function createDefaultWindowsAtomicHooks(options: {
|
|
|
370
414
|
};
|
|
371
415
|
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path') || 'Path';
|
|
372
416
|
env[pathKey] = `${path.dirname(portableNode)};${env[pathKey] || ''}`;
|
|
373
|
-
execFileSync(portableNode, [
|
|
417
|
+
const installOutput = String(execFileSync(portableNode, [
|
|
374
418
|
options.npmCliPath,
|
|
375
419
|
'install', '-g', `${options.packageName}@${options.targetVersion}`, '--force', '--prefer-online', '--prefix', stagedPrefix,
|
|
376
420
|
], {
|
|
@@ -379,7 +423,11 @@ export function createDefaultWindowsAtomicHooks(options: {
|
|
|
379
423
|
maxBuffer: 20 * 1024 * 1024,
|
|
380
424
|
windowsHide: true,
|
|
381
425
|
env,
|
|
382
|
-
});
|
|
426
|
+
}));
|
|
427
|
+
// On failure execFileSync throws with stdout attached to the Error; on
|
|
428
|
+
// success it was previously discarded. Surface the npm output either way so
|
|
429
|
+
// a silently-succeeding-then-rolled-back upgrade leaves a diagnosable trail.
|
|
430
|
+
if (installOutput.trim()) options.log(installOutput.trim());
|
|
383
431
|
},
|
|
384
432
|
restart: (portableNode, stagedCliEntry) => {
|
|
385
433
|
const restartArgv = options.restartArgv.map((arg, index) => index === 0 ? stagedCliEntry : arg);
|
|
@@ -406,23 +454,18 @@ export function createDefaultWindowsAtomicHooks(options: {
|
|
|
406
454
|
child.unref();
|
|
407
455
|
},
|
|
408
456
|
waitForHealth: async (pid, targetVersion) => {
|
|
409
|
-
const deadline = Date.now() +
|
|
457
|
+
const deadline = Date.now() + healthTimeoutMs;
|
|
410
458
|
while (Date.now() < deadline) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
});
|
|
422
|
-
req.on('timeout', () => req.destroy());
|
|
423
|
-
req.on('error', () => resolve({ ok: false }));
|
|
424
|
-
});
|
|
425
|
-
if (result.ok && result.pid === pid && result.body?.includes(targetVersion)) return true;
|
|
459
|
+
// Liveness + pid identity come from GET /health, whose body is
|
|
460
|
+
// {ok, pid, wsPath, port} — it carries NO version. The running version
|
|
461
|
+
// lives only in GET /api/v1/status → payload.status.version, so the
|
|
462
|
+
// version gate must fetch that endpoint separately. Requiring the raw
|
|
463
|
+
// /health body to include targetVersion is unsatisfiable and silently
|
|
464
|
+
// rolls every upgrade back.
|
|
465
|
+
const liveness = await fetchLocalHealth(healthPort);
|
|
466
|
+
const alive = liveness.ok && liveness.pid === pid;
|
|
467
|
+
const version = alive ? await fetchLocalStatusVersion(healthPort) : undefined;
|
|
468
|
+
if (alive && version === targetVersion) return true;
|
|
426
469
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
427
470
|
}
|
|
428
471
|
return false;
|
|
@@ -6,7 +6,7 @@ import { getMesh } from '../config/mesh-config.js';
|
|
|
6
6
|
import { detectCLI } from '../detection/cli-detector.js';
|
|
7
7
|
import { LOG } from '../logging/logger.js';
|
|
8
8
|
import { appendLedgerEntry } from './mesh-ledger.js';
|
|
9
|
-
import { buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, claimNextTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, getActiveDirectDispatches, isTaskReadonly, taskDependenciesSatisfied, meshTaskNotBeforeReady, meshTaskPriorityRank } from './mesh-work-queue.js';
|
|
9
|
+
import { buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, claimNextTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, getActiveDirectDispatches, isTaskReadonly, taskDependenciesSatisfied, meshTaskNotBeforeReady, meshTaskPriorityRank, requeueTask } from './mesh-work-queue.js';
|
|
10
10
|
import type { MeshWorkQueueEntry } from './mesh-work-queue.js';
|
|
11
11
|
import { fastForwardMeshNode } from './mesh-fast-forward.js';
|
|
12
12
|
import { createSessionDelivery, updateSessionDeliveryStatus } from './mesh-delivery-policy.js';
|
|
@@ -931,6 +931,105 @@ function isTargetNodeTransientlyUnresolved(mesh: any, task: MeshWorkQueueEntry):
|
|
|
931
931
|
return isWithinCloneBootstrapGrace(targetNodeId);
|
|
932
932
|
}
|
|
933
933
|
|
|
934
|
+
// ---------------------------------------------------------------------------
|
|
935
|
+
// DEAD-TARGET-SELFHEAL: unpin a queue task hard-pinned to a session/node that has
|
|
936
|
+
// died (absent from the live mesh) so a live idle session can claim it, instead of
|
|
937
|
+
// leaving it stranded 'pending' forever behind the target_session_constraint skip.
|
|
938
|
+
// ---------------------------------------------------------------------------
|
|
939
|
+
|
|
940
|
+
// Conservative age gate before a pinned-but-dead target is unpinned. A target that is
|
|
941
|
+
// merely briefly unassigned or momentarily reconnecting must not be reclaimed on the
|
|
942
|
+
// tick it drops out of view; we require the task to have been idle (no updatedAt bump)
|
|
943
|
+
// for at least this window first. Sized to comfortably outlast a transient P2P blip /
|
|
944
|
+
// reconnect while staying well inside the reclaim cadence of the rest of the file
|
|
945
|
+
// (AUTO_LAUNCH_AWAIT_CLAIM_MS is 90s; the stranded-reclaim watchdog fires on similar
|
|
946
|
+
// scales), so a real reconnect wins the race and the self-heal only fires on a target
|
|
947
|
+
// that is genuinely gone.
|
|
948
|
+
const DEAD_TARGET_GRACE_MS = 60_000;
|
|
949
|
+
|
|
950
|
+
interface DeadTargetVerdict {
|
|
951
|
+
/** The pinned target is confirmed dead and past the grace window → safe to unpin. */
|
|
952
|
+
dead: boolean;
|
|
953
|
+
/** True when the target NODE itself is absent from the live mesh (clear targetNodeId too). */
|
|
954
|
+
nodeDead: boolean;
|
|
955
|
+
/** Short reason string for the ledger/requeue. */
|
|
956
|
+
reason: string;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* Decide whether a task's hard target pin (targetSessionId and/or targetNodeId) points at
|
|
961
|
+
* something that has DIED — i.e. is absent from the live mesh snapshot — and has been so
|
|
962
|
+
* long enough (DEAD_TARGET_GRACE_MS since the task's last update) that unpinning is safe.
|
|
963
|
+
*
|
|
964
|
+
* Two definitive death signals, deliberately conservative to never race a reconnecting node:
|
|
965
|
+
*
|
|
966
|
+
* (1) NODE dead — the task pins a targetNodeId that matches NO node in the live mesh
|
|
967
|
+
* (the same `meshNodeIdMatches`-over-mesh.nodes signal the targetPinUnmatched relabel
|
|
968
|
+
* uses at the empty-candidate site). A pinned session on an absent node is unreachable
|
|
969
|
+
* regardless, so the session pin is dead too. `nodeDead` ⇒ clear targetNodeId as well.
|
|
970
|
+
* Excluded: a target that is only TRANSIENTLY unresolved (a freshly-cloned worktree
|
|
971
|
+
* still propagating / bootstrapping) — isTargetNodeTransientlyUnresolved gates it out.
|
|
972
|
+
*
|
|
973
|
+
* (2) SESSION dead on a LIVE LOCAL node — the target node IS present and is THIS daemon's
|
|
974
|
+
* node, but the pinned session is absent from the local instance manager
|
|
975
|
+
* (resolveSessionBusyVerdict === 'UNKNOWN'). Local session visibility is complete, so
|
|
976
|
+
* absence here is genuine death, not a busy/generating flip. We KEEP targetNodeId (only
|
|
977
|
+
* the session died; the node is healthy and can host a replacement claim).
|
|
978
|
+
*
|
|
979
|
+
* A live REMOTE node whose session is not in our idle view is NOT treated as dead: absence
|
|
980
|
+
* from the remote-idle mirror is explicitly UNKNOWN liveness (the session may be busy or its
|
|
981
|
+
* agent:ready pull merely lost), so unpinning it could race healthy in-flight work. Returns
|
|
982
|
+
* dead=false in that case, leaving the existing skip in place.
|
|
983
|
+
*/
|
|
984
|
+
function resolveDeadTargetVerdict(components: DaemonComponents, meshId: string, mesh: any, task: MeshWorkQueueEntry): DeadTargetVerdict {
|
|
985
|
+
const NOT_DEAD: DeadTargetVerdict = { dead: false, nodeDead: false, reason: '' };
|
|
986
|
+
const targetSessionId = readNonEmptyString(task.targetSessionId);
|
|
987
|
+
const targetNodeId = readNonEmptyString(task.targetNodeId);
|
|
988
|
+
if (!targetSessionId && !targetNodeId) return NOT_DEAD;
|
|
989
|
+
|
|
990
|
+
// Age gate: never reclaim a pin younger than the grace window (guards against a target
|
|
991
|
+
// that has only just dropped out of view for a momentary reconnect).
|
|
992
|
+
const lastUpdateMs = Date.parse(task.updatedAt || task.createdAt || '');
|
|
993
|
+
if (Number.isFinite(lastUpdateMs) && Date.now() - lastUpdateMs < DEAD_TARGET_GRACE_MS) return NOT_DEAD;
|
|
994
|
+
|
|
995
|
+
const nodes: any[] = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
|
|
996
|
+
|
|
997
|
+
// (1) NODE-dead — a pinned node absent from the live mesh, and NOT merely transiently
|
|
998
|
+
// unresolved (a propagating/bootstrapping clone). This is a permanent routing miss.
|
|
999
|
+
if (targetNodeId) {
|
|
1000
|
+
const nodePresent = nodes.some(n => meshNodeIdMatches(n, targetNodeId));
|
|
1001
|
+
if (!nodePresent) {
|
|
1002
|
+
if (isTargetNodeTransientlyUnresolved(mesh, task)) return NOT_DEAD;
|
|
1003
|
+
return { dead: true, nodeDead: true, reason: 'dead_target_node_absent' };
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// (2) SESSION-dead on a LIVE LOCAL node. Only meaningful when a session is pinned.
|
|
1008
|
+
if (targetSessionId) {
|
|
1009
|
+
// Resolve the pinned target's node (if any) to decide whether we can trust local
|
|
1010
|
+
// absence. Without a targetNodeId, fall back to whichever live node hosts the session
|
|
1011
|
+
// is unknowable here; treat that as a LOCAL check only (a session id we cannot see
|
|
1012
|
+
// locally on a node we cannot resolve remotely stays UNKNOWN → not dead).
|
|
1013
|
+
const node = targetNodeId
|
|
1014
|
+
? nodes.find(n => meshNodeIdMatches(n, targetNodeId))
|
|
1015
|
+
: undefined;
|
|
1016
|
+
// A pinned session on a REMOTE live node: absence from our view is UNKNOWN, not death.
|
|
1017
|
+
// Only a LOCAL node (or no node pin at all — same-daemon assumption) lets us conclude
|
|
1018
|
+
// death from local instance-manager absence.
|
|
1019
|
+
const nodeIsLocal = node ? isLocalAutoLaunchNode(node) : true;
|
|
1020
|
+
if (nodeIsLocal) {
|
|
1021
|
+
const verdict = resolveSessionBusyVerdict(components, targetSessionId);
|
|
1022
|
+
if (verdict === 'UNKNOWN') {
|
|
1023
|
+
// Session absent from the complete local session view → genuinely gone.
|
|
1024
|
+
return { dead: true, nodeDead: false, reason: 'dead_target_session_absent' };
|
|
1025
|
+
}
|
|
1026
|
+
// GENERATING / IDLE_CONFIRMED → the session is alive (possibly busy). Never disturb.
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
return NOT_DEAD;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
934
1033
|
/**
|
|
935
1034
|
* FALSE-BLOCKER-CLONE-QUEUE (stale-event clear): once a task whose actionable blocker we
|
|
936
1035
|
* previously paged either gets claimed or transitions to a self-resolving state, re-arm the
|
|
@@ -1929,6 +2028,32 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
1929
2028
|
continue;
|
|
1930
2029
|
}
|
|
1931
2030
|
if (task.targetSessionId) {
|
|
2031
|
+
// DEAD-TARGET-SELFHEAL: before the unconditional target_session_constraint skip,
|
|
2032
|
+
// check whether the pinned session/node has DIED (absent from the live mesh). A
|
|
2033
|
+
// hard-pinned task whose target is gone can NEVER re-enter 'assigned' (the claim
|
|
2034
|
+
// gate refuses every non-matching session) and this skip fires forever with no
|
|
2035
|
+
// liveness check — the triple-walled stranded-pending defect. If the pin is
|
|
2036
|
+
// confirmed dead past the grace window, requeue it (clearing the dead session
|
|
2037
|
+
// pin, and the node pin too when the NODE itself is gone) so a live idle session
|
|
2038
|
+
// can claim it. requeueTask counts toward maxTaskRetries → bounded self-heal that
|
|
2039
|
+
// auto-fails past the cap (the desired terminal state, unblocking dependents).
|
|
2040
|
+
const deadTarget = resolveDeadTargetVerdict(components, meshId, mesh, task);
|
|
2041
|
+
if (deadTarget.dead) {
|
|
2042
|
+
const requeued = requeueTask(meshId, task.id, {
|
|
2043
|
+
reason: deadTarget.reason,
|
|
2044
|
+
clearTargetSession: true,
|
|
2045
|
+
// Keep the node pin if only the SESSION died on a still-live node; clear it
|
|
2046
|
+
// when the NODE itself is absent (nothing to pin to).
|
|
2047
|
+
clearTargetNode: deadTarget.nodeDead,
|
|
2048
|
+
});
|
|
2049
|
+
if (requeued) {
|
|
2050
|
+
LOG.warn('MeshQueue', `DEAD-TARGET-SELFHEAL: task ${task.id} (mesh ${meshId}) was pinned to a dead target (${deadTarget.reason}); requeued${deadTarget.nodeDead ? ' and unpinned node' : ''} (requeueCount=${requeued.requeueCount ?? '?'}, status=${requeued.status}).`);
|
|
2051
|
+
}
|
|
2052
|
+
// Keep the skip for THIS tick (the requeue already flipped the row to
|
|
2053
|
+
// pending/failed); a later tick assigns/launches the now-unpinned task.
|
|
2054
|
+
markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'target_session_dead_requeued' });
|
|
2055
|
+
continue;
|
|
2056
|
+
}
|
|
1932
2057
|
markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'target_session_constraint' });
|
|
1933
2058
|
continue;
|
|
1934
2059
|
}
|