comfyui-mcp 0.52.113 → 0.52.115

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.
@@ -18,7 +18,7 @@ import { realpath } from "node:fs/promises";
18
18
  import { extname, basename, dirname, join } from "node:path";
19
19
  import { downloadModel, findResumablePartialForLocalDownload, liveListingHasEntry, managerJobFilename, resolveDownloadTarget, shouldDispatchDownloadToManager, verifyLandedModel, verifyManagerVisibility, } from "./model-resolver.js";
20
20
  import { ModelError } from "../utils/errors.js";
21
- import { readDownloadProgress, listInFlightDownloadProgress, persistDownloadJob, readPersistedDownloadJob, listPersistedDownloadJobs, findPersistedDownloadJob, removePersistedDownloadJobFor, clearDownloadProgress, persistedRecordsEnabled, PERSIST_OWNER, PERSISTED_INFLIGHT_STALE_MS, } from "./download-progress.js";
21
+ import { readDownloadProgress, listInFlightDownloadProgress, persistDownloadJob, currentDownloadTarget, listPersistedDownloadJobs, findPersistedDownloadJob, removePersistedDownloadJobFor, clearDownloadProgress, persistedRecordsEnabled, PERSIST_OWNER, PERSISTED_INFLIGHT_STALE_MS, } from "./download-progress.js";
22
22
  import { logger } from "../utils/logger.js";
23
23
  /** How often an in-flight job re-persists its record (liveness heartbeat). Must stay
24
24
  * comfortably below PERSISTED_INFLIGHT_STALE_MS so a live download is always fresh. */
@@ -311,6 +311,7 @@ dispatchToManagerOverride) {
311
311
  // when locally resolvable (two-URLs-one-dest dedup).
312
312
  const lookupKeys = destKey ? [reqKey, destKey] : [reqKey];
313
313
  const trayId = downloadIdFor(url);
314
+ const downloadTarget = currentDownloadTarget();
314
315
  // ADOPT ONLY AN IN-FLIGHT ENTRY (#420 codex round 3, rule 3): a FINISHED entry
315
316
  // under any key is treated as absent — it must never shadow a currently-downloading
316
317
  // writer reachable under another key, nor cause a retire that overwrites a live row.
@@ -375,6 +376,7 @@ dispatchToManagerOverride) {
375
376
  rec.status === "downloading" &&
376
377
  nowForAdopt - (rec.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS &&
377
378
  rec.trayId === trayId &&
379
+ rec.target === downloadTarget &&
378
380
  (rec.id === id || (rec.req_key !== undefined && rec.req_key === reqKey)) &&
379
381
  // A FRESH HEARTBEAT IS NOT A LIVE WRITER. The heartbeat is written every
380
382
  // 15s and only goes stale after 60, so a process that died a moment ago
@@ -417,6 +419,7 @@ dispatchToManagerOverride) {
417
419
  const job = {
418
420
  id,
419
421
  trayId,
422
+ target: downloadTarget,
420
423
  url,
421
424
  target_subfolder: targetSubfolder,
422
425
  filename,
@@ -773,11 +776,12 @@ function finalizeCancelled(job) {
773
776
  * URL. No-op outside the panel. Keeps the persisted copy in step with the job.
774
777
  * Returns whether the record was DURABLY replaced (so the heartbeat can retry a
775
778
  * transiently-failing terminal persist). */
776
- function persistJobRecord(job) {
779
+ function persistJobRecord(job, owner = PERSIST_OWNER) {
777
780
  return persistDownloadJob({
778
781
  id: job.id,
779
782
  trayId: job.trayId,
780
783
  progressId: job.progressId,
784
+ target: job.target,
781
785
  url: job.url,
782
786
  target_subfolder: job.target_subfolder,
783
787
  filename: job.filename,
@@ -797,7 +801,34 @@ function persistJobRecord(job) {
797
801
  disk_verified: job.disk_verified,
798
802
  verified_root: job.verified_root,
799
803
  reclaimed_dead: job.reclaimedDead,
800
- });
804
+ }, owner);
805
+ }
806
+ function boundedDownloadJobError(value) {
807
+ if (typeof value !== "string")
808
+ return undefined;
809
+ const compact = value.replace(/\s+/g, " ").trim();
810
+ return compact ? compact.slice(0, 400) : undefined;
811
+ }
812
+ /** Turn a stale/frozen in-flight record into the terminal state the tray is reporting. */
813
+ export function reconcileStalledDownloadRecord(job, error) {
814
+ if (job.status !== "downloading")
815
+ return true;
816
+ const wasPersisted = job.updated !== undefined;
817
+ job.status = "error";
818
+ job.error = boundedDownloadJobError(error);
819
+ job.finished_at = Date.now();
820
+ job.updated = job.finished_at;
821
+ // A reconstructed foreign record must be terminalized in its ORIGINAL owner file;
822
+ // writing a fresh current-owner snapshot leaves the original fresh downloading row
823
+ // authoritative to status. Legacy persisted records without an owner cannot be safely
824
+ // reconciled, so callers keep the event explicitly disclosed instead.
825
+ if (wasPersisted && !job.owner)
826
+ return false;
827
+ const durable = persistJobRecord(job, job.owner ?? PERSIST_OWNER);
828
+ // In non-panel mode the in-memory object is the authoritative status store. In panel
829
+ // mode a failed persist leaves the old downloading snapshot visible, so the caller must
830
+ // keep the event explicitly reconciled instead of emitting an unflagged FAILED row.
831
+ return durable || !persistedRecordsEnabled();
801
832
  }
802
833
  /** Rebuild an in-memory DownloadJob view from a persisted record (#529 adoption
803
834
  * after a reconnect). It is a read-only snapshot — there is no live AbortController
@@ -808,10 +839,13 @@ function jobFromPersisted(rec) {
808
839
  id: rec.id,
809
840
  trayId: rec.trayId,
810
841
  progressId: rec.progressId,
842
+ target: rec.target,
843
+ owner: rec.owner,
811
844
  url: rec.url,
812
845
  target_subfolder: rec.target_subfolder,
813
846
  filename: rec.filename,
814
847
  status: rec.status,
848
+ updated: rec.updated,
815
849
  path: rec.path,
816
850
  error: rec.error,
817
851
  started_at: rec.started_at,
@@ -842,10 +876,10 @@ function jobFromPersisted(rec) {
842
876
  * distinct URLs resolving to the same dest+auth). The id alone can't disambiguate, so
843
877
  * a by-id lookup/cancel must decline rather than silently act on the local one.
844
878
  * Excludes this process's own records (same owner) and heartbeat-stale ones. */
845
- function hasAmbiguousForeignSibling(id, localTrayId) {
879
+ function hasAmbiguousForeignSibling(id, localTrayId, localTarget) {
846
880
  const now = Date.now();
847
881
  return listPersistedDownloadJobs().some((rec) => rec.id === id &&
848
- rec.trayId !== localTrayId &&
882
+ (rec.trayId !== localTrayId || rec.target !== localTarget) &&
849
883
  rec.owner !== PERSIST_OWNER &&
850
884
  rec.status === "downloading" &&
851
885
  now - (rec.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS);
@@ -1015,7 +1049,7 @@ ctx) {
1015
1049
  /**
1016
1050
  * Every DISTINCT tracked download that answers to `id`, across this process's
1017
1051
  * registry AND the persisted store, deduped by the TRUE composite identity
1018
- * (id, trayId) — the same key `listDownloadJobs`/`findDownloadJob` already use.
1052
+ * (id, trayId, target) — the same target-aware key `listDownloadJobs` uses.
1019
1053
  *
1020
1054
  * `id` is derived from destination+auth, so two different SOURCE URLs landing on
1021
1055
  * one file deliberately share it (#467 P1-A dedup). That makes `id` a
@@ -1027,7 +1061,7 @@ ctx) {
1027
1061
  * Newest-started first, so a caller printing candidates leads with the live one.
1028
1062
  */
1029
1063
  export function listDownloadJobCandidates(id) {
1030
- const keyOf = (j) => `${j.id}\n${j.trayId}`;
1064
+ const keyOf = (j) => `${j.id}\n${j.trayId}\n${j.target ?? ""}`;
1031
1065
  const byKey = new Map();
1032
1066
  for (const e of new Set(jobs.values())) {
1033
1067
  if (e.job.id !== id)
@@ -1038,7 +1072,8 @@ export function listDownloadJobCandidates(id) {
1038
1072
  }
1039
1073
  // `trayId` is a hash of the URL, so it does NOT separate sessions: this
1040
1074
  // session's SETTLED record for a URL and ANOTHER session's still-running
1041
- // download of the same URL to the same destination collapse to one key. Which
1075
+ // download of the same URL to the same destination collapse to one target-aware
1076
+ // key. Which
1042
1077
  // one is reported matters — showing the local "cancelled" row while a foreign
1043
1078
  // transfer is live would hide a running download behind a stale verdict, and
1044
1079
  // that is worse than the ambiguity this function exists to surface.
@@ -1083,23 +1118,33 @@ export function listDownloadJobCandidates(id) {
1083
1118
  * Resolve ONE download.
1084
1119
  *
1085
1120
  * `trayId` is the DISAMBIGUATOR (#822): with it the lookup keys on the full
1086
- * composite identity (id, trayId) and can always name exactly one row. Without
1121
+ * composite identity (id, trayId, target) and can name exactly one row when the
1122
+ * current target is known. Without
1087
1123
  * it, an id that denotes more than one download still resolves to nothing —
1088
1124
  * but callers can now distinguish that from "no such download" by asking
1089
1125
  * {@link listDownloadJobCandidates}, which is the difference between an
1090
1126
  * actionable "say which one" and a false "it never existed".
1091
1127
  */
1092
- export function getDownloadJob(id, trayId) {
1093
- if (trayId) {
1094
- return listDownloadJobCandidates(id).find((j) => j.trayId === trayId);
1095
- }
1128
+ export function getDownloadJob(id, trayId, target) {
1129
+ const targetSelector = target ?? currentDownloadTarget();
1130
+ const candidates = listDownloadJobCandidates(id).filter((j) => (!trayId || j.trayId === trayId) &&
1131
+ (targetSelector === undefined || j.target === targetSelector));
1132
+ const selectOne = (rows) => {
1133
+ const identities = new Set(rows.map((j) => `${j.id}\n${j.trayId}\n${j.target ?? ""}`));
1134
+ return identities.size === 1 ? rows[0] : undefined;
1135
+ };
1136
+ if (trayId)
1137
+ return selectOne(candidates);
1096
1138
  // The registry indexes each job under its request key and (when local) its
1097
1139
  // destination key; the public id is one of those, so a direct get resolves it.
1098
1140
  const live = jobs.get(id)?.job;
1099
1141
  // A LIVE in-flight job in THIS process is authoritative (it's actively writing here) —
1100
- // but decline if a concurrent live FOREIGN session shares the id with a different trayId.
1101
- if (live && live.status === "downloading") {
1102
- if (hasAmbiguousForeignSibling(id, live.trayId))
1142
+ // but decline if a concurrent live FOREIGN session shares the id with a different
1143
+ // (trayId, target) identity.
1144
+ if (live &&
1145
+ (targetSelector === undefined || live.target === targetSelector) &&
1146
+ live.status === "downloading") {
1147
+ if (hasAmbiguousForeignSibling(id, live.trayId, live.target))
1103
1148
  return undefined;
1104
1149
  return live;
1105
1150
  }
@@ -1109,10 +1154,21 @@ export function getDownloadJob(id, trayId) {
1109
1154
  // same id. A cancel/error never lands a file, so it must never mask another session's
1110
1155
  // validated-complete file (cancelled-over-complete), and this holds whether the
1111
1156
  // cancelled/error record is in-memory or persisted.
1112
- if (live && live.status === "done")
1157
+ if (live &&
1158
+ (targetSelector === undefined || live.target === targetSelector) &&
1159
+ live.status === "done")
1113
1160
  return live;
1114
1161
  const now = Date.now();
1115
- const matches = listPersistedDownloadJobs().filter((r) => r.id === id);
1162
+ const matches = listPersistedDownloadJobs().filter((r) => r.id === id && (targetSelector === undefined || r.target === targetSelector));
1163
+ const freshIdentities = new Set(matches
1164
+ .filter((r) => r.status === "downloading" && now - (r.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS)
1165
+ .map((r) => `${r.id}\n${r.trayId}\n${r.target ?? ""}`));
1166
+ const settledIdentities = new Set(matches
1167
+ .filter((r) => r.status !== "downloading")
1168
+ .map((r) => `${r.id}\n${r.trayId}\n${r.target ?? ""}`));
1169
+ if (freshIdentities.size > 1 || (freshIdentities.size === 0 && settledIdentities.size > 1)) {
1170
+ return undefined;
1171
+ }
1116
1172
  const persistedDone = matches
1117
1173
  .filter((r) => r.status === "done")
1118
1174
  .sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))[0];
@@ -1123,11 +1179,12 @@ export function getDownloadJob(id, trayId) {
1123
1179
  // record, then the newest persisted terminal.
1124
1180
  const foreignLive = matches.filter((r) => r.status === "downloading" && now - (r.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS);
1125
1181
  if (foreignLive.length > 0) {
1126
- if (new Set(foreignLive.map((r) => r.trayId)).size > 1)
1182
+ if (new Set(foreignLive.map((r) => `${r.trayId}\n${r.target ?? ""}`)).size > 1)
1127
1183
  return undefined; // ambiguous live
1128
1184
  return jobFromPersisted(foreignLive.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))[0]);
1129
1185
  }
1130
- if (live)
1186
+ if (live &&
1187
+ (targetSelector === undefined || live.target === targetSelector))
1131
1188
  return live; // this session's terminal (cancelled/error), no DONE to prefer
1132
1189
  const terminal = matches.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))[0];
1133
1190
  return terminal ? jobFromPersisted(terminal) : undefined;
@@ -1158,8 +1215,15 @@ export function getDownloadJob(id, trayId) {
1158
1215
  */
1159
1216
  export function describeUnresolvedDownload(id) {
1160
1217
  const now = Date.now();
1161
- const liveInMemory = jobs.get(id)?.job;
1162
- const inFlight = listPersistedDownloadJobs().filter((r) => r.id === id && r.status === "downloading");
1218
+ const targetSelector = currentDownloadTarget();
1219
+ const liveInMemoryCandidate = jobs.get(id)?.job;
1220
+ const liveInMemory = liveInMemoryCandidate &&
1221
+ (targetSelector === undefined || liveInMemoryCandidate.target === targetSelector)
1222
+ ? liveInMemoryCandidate
1223
+ : undefined;
1224
+ const inFlight = listPersistedDownloadJobs().filter((r) => r.id === id &&
1225
+ r.status === "downloading" &&
1226
+ (targetSelector === undefined || r.target === targetSelector));
1163
1227
  const livePersisted = inFlight.filter((r) => now - (r.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS);
1164
1228
  // A STALE in-flight record counts here, hedged — and getting that wrong is
1165
1229
  // what would have left the REPORTED case uncovered.
@@ -1217,6 +1281,7 @@ export function describeUnresolvedDownload(id) {
1217
1281
  export function findDownloadJob(query) {
1218
1282
  const trayId = query.url ? downloadIdFor(query.url) : undefined;
1219
1283
  const destKey = query.destKey;
1284
+ const targetSelector = query.target ?? currentDownloadTarget();
1220
1285
  if (!trayId && !destKey)
1221
1286
  return undefined;
1222
1287
  // Gather candidate IN-FLIGHT jobs from BOTH this process's registry AND the cross-
@@ -1225,18 +1290,19 @@ export function findDownloadJob(query) {
1225
1290
  // job for URL→destA plus another session's persisted in-flight job for URL→destB are
1226
1291
  // two DISTINCT jobs for the same URL, and adopting either by URL alone would be a
1227
1292
  // guess — so decline (the caller must use the exact id).
1228
- // Key candidates by (id, trayId): two distinct URLs to one dest+auth share an id but
1293
+ // Key candidates by (id, trayId, target): two distinct URLs to one dest+auth share an id but
1229
1294
  // differ in trayId (distinct physical downloads), so id alone would wrongly collapse
1230
1295
  // them and mask the ambiguity. A live in-memory copy wins over its persisted snapshot
1231
1296
  // (same id+trayId key).
1232
- const keyOf = (id, tray) => `${id}\n${tray}`;
1297
+ const keyOf = (id, tray, targetValue) => `${id}\n${tray}\n${targetValue ?? ""}`;
1233
1298
  const now = Date.now();
1234
1299
  const inflightByKey = new Map();
1235
1300
  for (const e of new Set(jobs.values())) {
1236
1301
  if (e.job.status !== "downloading")
1237
1302
  continue;
1238
- if ((query.url && e.job.url === query.url) || (destKey && e.job.destKey === destKey)) {
1239
- inflightByKey.set(keyOf(e.job.id, e.job.trayId), e.job);
1303
+ if (((query.url && e.job.url === query.url) || (destKey && e.job.destKey === destKey)) &&
1304
+ (targetSelector === undefined || e.job.target === targetSelector)) {
1305
+ inflightByKey.set(keyOf(e.job.id, e.job.trayId, e.job.target), e.job);
1240
1306
  }
1241
1307
  }
1242
1308
  for (const rec of listPersistedDownloadJobs()) {
@@ -1246,7 +1312,9 @@ export function findDownloadJob(query) {
1246
1312
  if (rec.status !== "downloading" || now - (rec.updated ?? 0) >= PERSISTED_INFLIGHT_STALE_MS) {
1247
1313
  continue;
1248
1314
  }
1249
- const key = keyOf(rec.id, rec.trayId);
1315
+ if (targetSelector !== undefined && rec.target !== targetSelector)
1316
+ continue;
1317
+ const key = keyOf(rec.id, rec.trayId, rec.target);
1250
1318
  if (inflightByKey.has(key))
1251
1319
  continue;
1252
1320
  if ((trayId && rec.trayId === trayId) || (destKey && rec.dest_key === destKey)) {
@@ -1259,7 +1327,7 @@ export function findDownloadJob(query) {
1259
1327
  return [...inflightByKey.values()][0];
1260
1328
  // No in-flight match anywhere — fall back to a single unambiguous SETTLED persisted
1261
1329
  // record (for status reporting); findPersistedDownloadJob declines if ambiguous.
1262
- const persisted = findPersistedDownloadJob({ trayId, destKey });
1330
+ const persisted = findPersistedDownloadJob({ trayId, destKey, target: targetSelector });
1263
1331
  return persisted ? jobFromPersisted(persisted) : undefined;
1264
1332
  }
1265
1333
  /**
@@ -1287,12 +1355,11 @@ export function compareTrayIds(a, b) {
1287
1355
  }
1288
1356
  export function listDownloadJobs() {
1289
1357
  // One Entry is indexed under multiple keys — dedup by identity so a job appears once
1290
- // regardless of how many keys point at it. Identity is (id, trayId), NOT id alone:
1291
- // two distinct URLs to one dest+auth share an id but are distinct physical downloads
1292
- // (different trayId), and both must be listed — download_model action:"status" with no selector
1293
- // promises EVERY tracked download.
1358
+ // regardless of how many keys point at it. Identity is (id, trayId, target), NOT id alone:
1359
+ // concurrent local and pod transfers can share the first two fields and must remain
1360
+ // separately visible to the tray reconciliation and status action.
1294
1361
  const seen = new Set();
1295
- const keyOf = (j) => `${j.id}\n${j.trayId}`;
1362
+ const keyOf = (j) => `${j.id}\n${j.trayId}\n${j.target ?? ""}`;
1296
1363
  const byKey = new Map();
1297
1364
  for (const e of jobs.values()) {
1298
1365
  if (seen.has(e))
@@ -1305,11 +1372,10 @@ export function listDownloadJobs() {
1305
1372
  }
1306
1373
  // #529: fold in persisted records for jobs THIS session's registry doesn't hold
1307
1374
  // (started before a reconnect), so download_model action:"status" still lists in-flight downloads.
1308
- // INTEGRITY TRUTH: a validated DONE (file landed) WINS over a cancelled/error record for
1309
- // the SAME (id, trayId) — whether that other record is a persisted counterpart OR this
1310
- // session's own in-memory cancelled/error. So a persisted DONE overrides any current
1311
- // entry that is neither DONE nor a LIVE in-memory download (which stays visible as the
1312
- // user's active action). The list never shows a cancelled in place of a validated file.
1375
+ // INTEGRITY TRUTH: a LIVE or validated DONE record WINS over a cancelled/error snapshot
1376
+ // for the SAME (id, trayId, target) — whether that other record is persisted or this session's
1377
+ // in-memory terminal entry. A live record must stay visible as the user's active action,
1378
+ // and the list must never replace a validated file with a terminal snapshot.
1313
1379
  for (const rec of listPersistedDownloadJobs()) {
1314
1380
  const job = jobFromPersisted(rec);
1315
1381
  const k = keyOf(job);
@@ -1317,7 +1383,18 @@ export function listDownloadJobs() {
1317
1383
  if (!cur) {
1318
1384
  byKey.set(k, job);
1319
1385
  }
1320
- else if (job.status === "done" && cur.status !== "done" && cur.status !== "downloading") {
1386
+ else if ((job.status === "downloading" &&
1387
+ job.staleInflight !== true &&
1388
+ cur.status !== "done" &&
1389
+ !(cur.status === "downloading" && cur.staleInflight !== true)) ||
1390
+ // A persisted terminal may replace a stale persisted in-flight snapshot, but
1391
+ // never an active in-memory writer. The latter has no heartbeat `updated` field;
1392
+ // comparing the terminal timestamp against zero made an old terminal shadow the
1393
+ // bytes that are still advancing in this process (#2057 recurrence).
1394
+ (job.status !== "downloading" &&
1395
+ cur.status === "downloading" &&
1396
+ cur.staleInflight === true) ||
1397
+ (job.status === "done" && cur.status !== "done" && cur.status !== "downloading")) {
1321
1398
  byKey.set(k, job);
1322
1399
  }
1323
1400
  }
@@ -1504,6 +1581,7 @@ export async function adoptOrphanedDownloadJobs() {
1504
1581
  if (routeToManager)
1505
1582
  return adopted;
1506
1583
  const now = Date.now();
1584
+ const target = currentDownloadTarget();
1507
1585
  const records = listPersistedDownloadJobs();
1508
1586
  // Ids this pass already re-issued. `records` is a SNAPSHOT taken before the loop,
1509
1587
  // so without this a second dead record for the SAME id (two dead sessions each
@@ -1515,6 +1593,11 @@ export async function adoptOrphanedDownloadJobs() {
1515
1593
  for (const rec of records) {
1516
1594
  if (rec.status !== "downloading")
1517
1595
  continue;
1596
+ // Adoption runs in the current target's process. A targetless record cannot
1597
+ // be used to adopt a local/pod transfer when the process has a real target,
1598
+ // and a targetful record must never be adopted by a targetless process.
1599
+ if (rec.target !== target)
1600
+ continue;
1518
1601
  if (!rec.owner || rec.owner === PERSIST_OWNER)
1519
1602
  continue;
1520
1603
  if (rec.via_manager)
@@ -1523,10 +1606,13 @@ export async function adoptOrphanedDownloadJobs() {
1523
1606
  continue;
1524
1607
  if (!rec.url || downloadIdFor(rec.url) !== rec.trayId)
1525
1608
  continue;
1526
- if (adoptedIds.has(rec.id))
1609
+ const identity = `${rec.id}\n${rec.trayId}\n${rec.target ?? ""}`;
1610
+ if (adoptedIds.has(identity))
1527
1611
  continue;
1528
1612
  const alreadyAdopted = records.some((other) => other !== rec &&
1529
1613
  other.id === rec.id &&
1614
+ other.trayId === rec.trayId &&
1615
+ other.target === rec.target &&
1530
1616
  other.status === "downloading" &&
1531
1617
  now - (other.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS);
1532
1618
  if (alreadyAdopted)
@@ -1565,7 +1651,7 @@ export async function adoptOrphanedDownloadJobs() {
1565
1651
  resumedBytes: partial.bytes,
1566
1652
  staleRecordLeft: reclaim.staleRecordLeft || undefined,
1567
1653
  });
1568
- adoptedIds.add(rec.id);
1654
+ adoptedIds.add(identity);
1569
1655
  }
1570
1656
  return adopted;
1571
1657
  }
@@ -1590,11 +1676,32 @@ export function cancelDownloadJob(id,
1590
1676
  * it the composite identity (id, trayId) selects exactly one row, which is what
1591
1677
  * made the stale job in #822 unselectable — and therefore unstoppable. */
1592
1678
  trayId) {
1679
+ const targetSelector = currentDownloadTarget();
1593
1680
  if (trayId) {
1681
+ const trayCandidates = listDownloadJobCandidates(id).filter((j) => j.trayId === trayId &&
1682
+ (targetSelector === undefined || j.target === targetSelector));
1683
+ if (targetSelector === undefined) {
1684
+ const identities = new Set(listDownloadJobCandidates(id)
1685
+ .filter((j) => j.trayId === trayId)
1686
+ .map((j) => `${j.id}\n${j.trayId}\n${j.target ?? ""}`));
1687
+ if (identities.size > 1) {
1688
+ return {
1689
+ found: true,
1690
+ owned: false,
1691
+ aborted: false,
1692
+ ambiguous: true,
1693
+ candidates: listDownloadJobCandidates(id),
1694
+ status: trayCandidates[0]?.status,
1695
+ job: trayCandidates[0],
1696
+ };
1697
+ }
1698
+ }
1594
1699
  // Explicit selection: find the ONE local entry with this composite identity.
1595
1700
  // Scanning entries (rather than jobs.get(id)) matters because the registry's
1596
1701
  // id row holds only one of several same-destination jobs.
1597
- const entry = [...new Set(jobs.values())].find((e) => e.job.id === id && e.job.trayId === trayId);
1702
+ const entry = [...new Set(jobs.values())].find((e) => e.job.id === id &&
1703
+ e.job.trayId === trayId &&
1704
+ (targetSelector === undefined || e.job.target === targetSelector));
1598
1705
  if (entry) {
1599
1706
  const { job, controller } = entry;
1600
1707
  // No ambiguity check here: the caller named the exact download, which is
@@ -1610,10 +1717,13 @@ trayId) {
1610
1717
  // Not ours. It may be another session's (persisted). A LIVE one is reportable,
1611
1718
  // not abortable — but a STALE one whose writer is proven gone is reclaimed
1612
1719
  // (#858): closed as cancelled so the download can be safely re-issued.
1613
- const foreign = listDownloadJobCandidates(id).find((j) => j.trayId === trayId);
1720
+ const foreign = trayCandidates[0];
1614
1721
  if (foreign) {
1615
1722
  if (foreign.status === "downloading" && foreign.staleInflight) {
1616
- const rec = listPersistedDownloadJobs().find((r) => r.id === id && r.trayId === trayId && r.status === "downloading");
1723
+ const rec = listPersistedDownloadJobs().find((r) => r.id === id &&
1724
+ r.trayId === trayId &&
1725
+ r.status === "downloading" &&
1726
+ (targetSelector === undefined || r.target === targetSelector));
1617
1727
  if (rec) {
1618
1728
  const re = reclaimDeadPersistedDownload(rec);
1619
1729
  if (re.reclaimed) {
@@ -1642,12 +1752,16 @@ trayId) {
1642
1752
  return { found: false, owned: false, aborted: false, candidates: listDownloadJobCandidates(id) };
1643
1753
  }
1644
1754
  const entry = jobs.get(id);
1645
- if (entry) {
1646
- const { job, controller } = entry;
1755
+ const localEntry = entry &&
1756
+ (targetSelector === undefined || entry.job.target === targetSelector)
1757
+ ? entry
1758
+ : undefined;
1759
+ if (localEntry) {
1760
+ const { job, controller } = localEntry;
1647
1761
  // Cross-session ambiguity: a live foreign download shares this id with a DIFFERENT
1648
1762
  // trayId. Aborting by id could act on the wrong logical download — decline so a
1649
1763
  // cancel-by-id can never hit an unintended concurrent download (a #515 invariant).
1650
- if (job.status === "downloading" && hasAmbiguousForeignSibling(id, job.trayId)) {
1764
+ if (job.status === "downloading" && hasAmbiguousForeignSibling(id, job.trayId, job.target)) {
1651
1765
  return {
1652
1766
  found: true,
1653
1767
  owned: true,
@@ -1694,7 +1808,7 @@ trayId) {
1694
1808
  // tray id of the one they mean. Only STILL-DOWNLOADING records create that
1695
1809
  // ambiguity: a settled record cannot be cancelled wrongly (the call is a no-op
1696
1810
  // report either way), so it must not block the one row that could be acted on.
1697
- const persistedCandidates = listDownloadJobCandidates(id);
1811
+ const persistedCandidates = listDownloadJobCandidates(id).filter((candidate) => targetSelector === undefined || candidate.target === targetSelector);
1698
1812
  if (persistedCandidates.filter((c) => c.status === "downloading").length > 1) {
1699
1813
  return {
1700
1814
  found: true,
@@ -1706,7 +1820,24 @@ trayId) {
1706
1820
  job: persistedCandidates[0],
1707
1821
  };
1708
1822
  }
1709
- const persisted = readPersistedDownloadJob(id);
1823
+ const persistedMatches = listPersistedDownloadJobs().filter((r) => r.id === id && (targetSelector === undefined || r.target === targetSelector));
1824
+ const persistedLive = persistedMatches.filter((r) => r.status === "downloading" && Date.now() - (r.updated ?? 0) < PERSISTED_INFLIGHT_STALE_MS);
1825
+ const persistedIdentities = new Set(persistedLive.map((r) => `${r.id}\n${r.trayId}\n${r.target ?? ""}`));
1826
+ if (persistedIdentities.size > 1) {
1827
+ return {
1828
+ found: true,
1829
+ owned: false,
1830
+ aborted: false,
1831
+ ambiguous: true,
1832
+ candidates: persistedCandidates,
1833
+ status: persistedCandidates[0]?.status,
1834
+ job: persistedCandidates[0],
1835
+ };
1836
+ }
1837
+ const persistedPool = persistedMatches.filter((r) => r.status === "done");
1838
+ const persisted = (persistedLive[0] ?? (persistedPool.length > 0
1839
+ ? persistedPool.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))[0]
1840
+ : persistedMatches.sort((a, b) => (b.updated ?? 0) - (a.updated ?? 0))[0])) ?? null;
1710
1841
  if (persisted) {
1711
1842
  // A STALE in-flight record whose writer is proven gone is reclaimed (#858);
1712
1843
  // a live or unprovable one stays refused exactly as before (#761).