pixivflow 2.19.3 → 2.19.5

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.
Files changed (30) hide show
  1. package/dist/commands/scheduler-runtime.js +4 -15
  2. package/dist/config/defaults.d.ts +1 -0
  3. package/dist/config/defaults.js +4 -0
  4. package/dist/config/environment.js +19 -0
  5. package/dist/config/types.d.ts +21 -0
  6. package/dist/config/validation.js +15 -0
  7. package/dist/delivery/DeliveryAck.d.ts +14 -0
  8. package/dist/delivery/DeliveryAck.js +18 -0
  9. package/dist/delivery/DeliveryService.d.ts +9 -0
  10. package/dist/delivery/DeliveryService.js +11 -0
  11. package/dist/delivery/OutboxWorker.js +13 -0
  12. package/dist/delivery/settleDeliveryTerminal.d.ts +25 -0
  13. package/dist/delivery/settleDeliveryTerminal.js +40 -0
  14. package/dist/download/DownloadManager.js +12 -1
  15. package/dist/download/handlers/IllustrationTargetHandler.d.ts +42 -1
  16. package/dist/download/handlers/IllustrationTargetHandler.js +238 -37
  17. package/dist/download/handlers/NovelTargetHandler.d.ts +47 -0
  18. package/dist/download/handlers/NovelTargetHandler.js +211 -27
  19. package/dist/download/pipeline/DownloadPipeline.d.ts +22 -3
  20. package/dist/download/pipeline/DownloadPipeline.js +109 -92
  21. package/dist/download/plan/DownloadPlanner.d.ts +47 -1
  22. package/dist/download/plan/DownloadPlanner.js +105 -15
  23. package/dist/package.json +1 -1
  24. package/dist/scheduler/TargetOutcome.d.ts +195 -2
  25. package/dist/scheduler/TargetOutcome.js +202 -3
  26. package/dist/storage/repositories/DeliveryRepository.d.ts +12 -0
  27. package/dist/storage/repositories/DeliveryRepository.js +18 -1
  28. package/dist/version.js +1 -1
  29. package/dist/webui/package.json +1 -1
  30. package/package.json +1 -1
@@ -59,6 +59,7 @@ const schedules_1 = require("../scheduler/schedules");
59
59
  const SlotCoordinator_1 = require("../scheduler/SlotCoordinator");
60
60
  const DeliveryLedgerPort_1 = require("../delivery/DeliveryLedgerPort");
61
61
  const OutboxWorker_1 = require("../delivery/OutboxWorker");
62
+ const settleDeliveryTerminal_1 = require("../delivery/settleDeliveryTerminal");
62
63
  const LegacyOutboxMigration_1 = require("../delivery/LegacyOutboxMigration");
63
64
  const NotificationPolicy_1 = require("../notification/NotificationPolicy");
64
65
  const node_crypto_1 = require("node:crypto");
@@ -241,22 +242,10 @@ async function createSchedulerRuntime(configPathArg) {
241
242
  const outboxWorker = new OutboxWorker_1.OutboxWorker(database, deliveryDispatcher, {
242
243
  retryBaseMs: config.delivery?.outboxRetryBaseMs,
243
244
  retryMaxMs: config.delivery?.outboxRetryMaxMs,
244
- // A confirmed ACK promotes the delivery_pending cell to submitted.
245
+ // A confirmed ACK settles the owning Slot cell (submitted / duplicate /
246
+ // failed); see settleDeliveryTerminal for the invariant it enforces.
245
247
  onDeliveryTerminal: (deliveryId, ack) => {
246
- const row = database.deliveries.getById(deliveryId);
247
- if (!row || !row.slotId || !row.targetId)
248
- return;
249
- if (ack.kind === 'duplicate_existing') {
250
- const coord = new SlotCoordinator_1.SlotCoordinator(database);
251
- coord.applyOutcome(row.slotId, row.targetId, {
252
- kind: 'duplicate',
253
- workId: row.pixivId,
254
- reason: 'downstream attested historical duplicate',
255
- });
256
- return;
257
- }
258
- const coord = new SlotCoordinator_1.SlotCoordinator(database);
259
- coord.markDelivered(row.slotId, row.targetId, row.pixivId, row.workType);
248
+ (0, settleDeliveryTerminal_1.settleDeliveryTerminal)(database, deliveryId, ack);
260
249
  },
261
250
  });
262
251
  const notificationPolicy = new NotificationPolicy_1.NotificationPolicy(database, config);
@@ -38,6 +38,7 @@ export declare const DEFAULT_CONFIG: {
38
38
  readonly maxRetries: 3;
39
39
  readonly retryDelay: 2000;
40
40
  readonly timeout: 60000;
41
+ readonly candidateScanLimit: 5;
41
42
  };
42
43
  readonly initialDelay: 0;
43
44
  };
@@ -41,6 +41,10 @@ exports.DEFAULT_CONFIG = {
41
41
  maxRetries: 3,
42
42
  retryDelay: 2000,
43
43
  timeout: 60000,
44
+ // Candidates one execution may ATTEMPT while looking for an eligible work.
45
+ // Bounded so a ranking page full of already-delivered works cannot loop,
46
+ // but large enough that a single duplicate no longer burns the slot.
47
+ candidateScanLimit: 5,
44
48
  },
45
49
  initialDelay: 0,
46
50
  };
@@ -101,6 +101,25 @@ function applyEnvironmentOverrides(config) {
101
101
  overridden.scheduler.enabled = process.env.PIXIV_SCHEDULER_ENABLED.toLowerCase() === 'true';
102
102
  }
103
103
  }
104
+ // Override the bounded candidate-scan window. A non-numeric or non-positive
105
+ // value is ignored (the config-file value stays authoritative) rather than
106
+ // silently becoming NaN, which would disable the bound entirely.
107
+ if (process.env.PIXIV_CANDIDATE_SCAN_LIMIT !== undefined) {
108
+ const parsed = Number.parseInt(process.env.PIXIV_CANDIDATE_SCAN_LIMIT, 10);
109
+ if (Number.isFinite(parsed) && parsed > 0) {
110
+ if (!overridden.download) {
111
+ overridden.download = { candidateScanLimit: parsed };
112
+ }
113
+ else {
114
+ overridden.download.candidateScanLimit = parsed;
115
+ }
116
+ }
117
+ else {
118
+ logger_1.logger.warn('Ignoring PIXIV_CANDIDATE_SCAN_LIMIT: expected a positive integer', {
119
+ value: process.env.PIXIV_CANDIDATE_SCAN_LIMIT,
120
+ });
121
+ }
122
+ }
104
123
  // Override proxy from environment variables
105
124
  // Priority: all_proxy > https_proxy > http_proxy
106
125
  const proxyUrl = process.env.all_proxy || process.env.ALL_PROXY ||
@@ -81,6 +81,20 @@ export interface TargetConfig {
81
81
  * Maximum works to download per execution for this tag.
82
82
  */
83
83
  limit?: number;
84
+ /**
85
+ * Maximum CANDIDATE works one execution may attempt (not produce) while
86
+ * looking for an eligible work. A scheduled "one post per slot" target has
87
+ * `limit: 1`, so without this the run would hold a single candidate: if that
88
+ * one candidate is already delivered / deleted / private, the slot ends with
89
+ * nothing submitted. The scan walks up to N candidates in ranking order,
90
+ * skipping unusable ones, and is strictly bounded so a page full of
91
+ * duplicates cannot loop.
92
+ *
93
+ * Overrides `download.candidateScanLimit` / `PIXIV_CANDIDATE_SCAN_LIMIT`.
94
+ * Clamped to 1..100 and never below `limit`, so a multi-work target can still
95
+ * fill its own limit.
96
+ */
97
+ candidateScanLimit?: number;
84
98
  /**
85
99
  * Search target parameter for Pixiv API.
86
100
  */
@@ -655,6 +669,13 @@ export interface StandaloneConfig {
655
669
  * Default: 60000
656
670
  */
657
671
  timeout?: number;
672
+ /**
673
+ * How many CANDIDATE works one execution may attempt while looking for an
674
+ * eligible one. Per-target `candidateScanLimit` overrides this.
675
+ *
676
+ * Default: 5 (clamped to 1..100)
677
+ */
678
+ candidateScanLimit?: number;
658
679
  };
659
680
  }
660
681
  //# sourceMappingURL=types.d.ts.map
@@ -469,6 +469,21 @@ function validateConfig(config, location, databasePath) {
469
469
  if (config.download.maxRetries !== undefined && (config.download.maxRetries < 0 || config.download.maxRetries > 10)) {
470
470
  warnings.push('download.maxRetries: Should be between 0 and 10');
471
471
  }
472
+ if (config.download.candidateScanLimit !== undefined &&
473
+ (!Number.isInteger(config.download.candidateScanLimit) ||
474
+ config.download.candidateScanLimit < 1 ||
475
+ config.download.candidateScanLimit > 100)) {
476
+ warnings.push('download.candidateScanLimit: Should be an integer between 1 and 100');
477
+ }
478
+ }
479
+ // The bounded candidate scan is what stops a page full of duplicates from
480
+ // burning a whole scheduled slot, so a non-integer value is reported here.
481
+ for (const target of config.targets ?? []) {
482
+ if (target.candidateScanLimit === undefined)
483
+ continue;
484
+ if (!Number.isInteger(target.candidateScanLimit) || target.candidateScanLimit < 1 || target.candidateScanLimit > 100) {
485
+ warnings.push(`targets.${target.id ?? target.tag ?? '?'}.candidateScanLimit: Should be an integer between 1 and 100`);
486
+ }
472
487
  }
473
488
  // Validate log level
474
489
  if (config.logLevel && !['debug', 'info', 'warn', 'error'].includes(config.logLevel)) {
@@ -35,6 +35,20 @@ export type DeliveryAck = {
35
35
  matchedKey?: string;
36
36
  raw?: unknown;
37
37
  }
38
+ /**
39
+ * The provider accepted the request and persisted a record, but that record
40
+ * is in a TERMINAL FAILURE state (`failed`/`rejected`/`invalid`/`expired`).
41
+ * Nothing will ever be published from this intent, and because the provider
42
+ * keys its record by OUR idempotency_key, another attempt only returns the
43
+ * same failed record. Terminal: never retried, never reported as delivered.
44
+ */
45
+ | {
46
+ kind: 'remote_failed';
47
+ remoteId?: string;
48
+ remoteStatus: string;
49
+ error: string;
50
+ raw?: unknown;
51
+ }
38
52
  /** Transient failure: timeouts, 5xx, 429, connection errors. Retryable. */
39
53
  | {
40
54
  kind: 'retryable_failure';
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseDeliveryAck = parseDeliveryAck;
4
+ /** Downstream record states that will never become a published delivery. */
5
+ const TERMINAL_REMOTE_STATUSES = new Set(['failed', 'rejected', 'invalid', 'expired']);
4
6
  const DEFAULT_HINTS = {
5
7
  dataPath: 'data',
6
8
  idField: 'review_id',
@@ -49,6 +51,22 @@ function parseDeliveryAck(status, body, hints = {}) {
49
51
  const remoteId = typeof idValue === 'number' ? String(idValue) : asString(idValue);
50
52
  const remoteStatus = asString(rec[h.statusField]);
51
53
  const reused = rec[h.reusedField] === true;
54
+ // A 2xx envelope can still describe a RECORD THAT FAILED downstream: the
55
+ // provider persists the record before doing the real work, so "accepted" and
56
+ // "the persisted record is broken" arrive on the same HTTP status. The record
57
+ // is what gets published (and what a retry will find again), so its terminal
58
+ // state outranks the transport-level success of this request — otherwise a
59
+ // remote failure is recorded here as an end-to-end success.
60
+ const terminalStatus = remoteStatus?.trim().toLowerCase();
61
+ if (terminalStatus && TERMINAL_REMOTE_STATUSES.has(terminalStatus)) {
62
+ return {
63
+ kind: 'remote_failed',
64
+ remoteId,
65
+ remoteStatus: terminalStatus,
66
+ error: `delivery endpoint reported terminal status ${terminalStatus}`,
67
+ raw: body,
68
+ };
69
+ }
52
70
  if (!reused) {
53
71
  return { kind: 'accepted', remoteId, remoteStatus, raw: body };
54
72
  }
@@ -35,6 +35,15 @@ export declare class DeliveryService {
35
35
  isAlreadyDelivered(deliveryTarget: string, workType: string, pixivId: string): boolean;
36
36
  /** Batch form for pre-lock candidate filtering. */
37
37
  deliveredIds(deliveryTarget: string, workType: string, pixivIds: string[]): Set<string>;
38
+ /**
39
+ * Bulk CANDIDATE SELECTION dedupe: works already delivered to this target, or
40
+ * whose review submission is still PENDING an answer. A pending work is
41
+ * already submitted for review, so it must not be selected (and submitted)
42
+ * again; the scan moves on to the next candidate instead. Within-slot RESUME
43
+ * keeps using `isAlreadyDelivered` — a cell continuing its OWN pending work is
44
+ * resuming it, not duplicating it.
45
+ */
46
+ submittedIds(deliveryTarget: string, workType: string, pixivIds: string[]): Set<string>;
38
47
  /**
39
48
  * Atomically create the delivery intent + outbox row and advance the cell.
40
49
  * Missing local artifact files are treated as a recoverable error (the
@@ -33,6 +33,17 @@ class DeliveryService {
33
33
  deliveredIds(deliveryTarget, workType, pixivIds) {
34
34
  return this.database.deliveries.deliveredIds(deliveryTarget, workType, pixivIds);
35
35
  }
36
+ /**
37
+ * Bulk CANDIDATE SELECTION dedupe: works already delivered to this target, or
38
+ * whose review submission is still PENDING an answer. A pending work is
39
+ * already submitted for review, so it must not be selected (and submitted)
40
+ * again; the scan moves on to the next candidate instead. Within-slot RESUME
41
+ * keeps using `isAlreadyDelivered` — a cell continuing its OWN pending work is
42
+ * resuming it, not duplicating it.
43
+ */
44
+ submittedIds(deliveryTarget, workType, pixivIds) {
45
+ return this.database.deliveries.submittedIds(deliveryTarget, workType, pixivIds);
46
+ }
36
47
  /**
37
48
  * Atomically create the delivery intent + outbox row and advance the cell.
38
49
  * Missing local artifact files are treated as a recoverable error (the
@@ -312,6 +312,19 @@ class OutboxWorker {
312
312
  });
313
313
  this.onDeliveryTerminal?.(row.deliveryId, ack, payload);
314
314
  break;
315
+ case 'remote_failed':
316
+ // The provider persisted the record, but it will never publish and the
317
+ // idempotency key pins us to that same record forever: retrying cannot
318
+ // change the outcome. Terminal, so the failure reaches the Slot instead
319
+ // of being masked as a delivered cell. Content is kept for inspection.
320
+ this.database.deliveries.recordAck(row.deliveryId, {
321
+ status: 'failed',
322
+ remoteId: ack.remoteId,
323
+ remoteStatus: ack.remoteStatus,
324
+ error: ack.error,
325
+ });
326
+ this.onDeliveryTerminal?.(row.deliveryId, ack, payload);
327
+ break;
315
328
  case 'permanent_failure':
316
329
  // Deterministic rejection: still retry a couple times to survive a
317
330
  // misconfigured blip, the outbox max-attempts then dead-letters it.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Terminal-ACK settlement: the ONE place a confirmed downstream delivery ACK
3
+ * becomes a Slot cell state.
4
+ *
5
+ * The outbox worker already recorded the durable delivery ledger outcome before
6
+ * calling here; this maps that ack onto the Slot FSM. Keeping it as a named,
7
+ * dependency-light function (instead of an inline closure in the scheduler
8
+ * runtime) is what makes the central invariant directly testable:
9
+ *
10
+ * REMOTE FAILURE MUST NOT BE REPORTED AS END-TO-END SUCCESS.
11
+ *
12
+ * A provider can answer HTTP 2xx while the record it persisted is terminally
13
+ * broken. The provider keys that record by OUR idempotency key, so a retry only
14
+ * returns the same broken record. Such an ack must settle the cell as `failed`,
15
+ * never promote it to `submitted` as though the content had been published.
16
+ */
17
+ import type { Database } from '../storage/Database';
18
+ import type { DeliveryAck } from './DeliveryAck';
19
+ /**
20
+ * Apply a terminal delivery ack to the Slot cell that owns the delivery intent.
21
+ * Returns false when the delivery has no Slot cell (ad-hoc / batch runs), which
22
+ * is a normal no-op rather than an error.
23
+ */
24
+ export declare function settleDeliveryTerminal(database: Database, deliveryId: string, ack: DeliveryAck): boolean;
25
+ //# sourceMappingURL=settleDeliveryTerminal.d.ts.map
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.settleDeliveryTerminal = settleDeliveryTerminal;
4
+ const SlotCoordinator_1 = require("../scheduler/SlotCoordinator");
5
+ /**
6
+ * Apply a terminal delivery ack to the Slot cell that owns the delivery intent.
7
+ * Returns false when the delivery has no Slot cell (ad-hoc / batch runs), which
8
+ * is a normal no-op rather than an error.
9
+ */
10
+ function settleDeliveryTerminal(database, deliveryId, ack) {
11
+ const row = database.deliveries.getById(deliveryId);
12
+ if (!row || !row.slotId || !row.targetId)
13
+ return false;
14
+ const coord = new SlotCoordinator_1.SlotCoordinator(database);
15
+ if (ack.kind === 'duplicate_existing') {
16
+ // Historical duplicate: a record from a DIFFERENT intent already exists, so
17
+ // THIS intent published nothing. Never a successful submission.
18
+ coord.applyOutcome(row.slotId, row.targetId, {
19
+ kind: 'duplicate',
20
+ workId: row.pixivId,
21
+ reason: 'downstream attested historical duplicate',
22
+ });
23
+ return true;
24
+ }
25
+ if (ack.kind === 'remote_failed') {
26
+ // The downstream record is terminally broken, so nothing was published:
27
+ // settle the cell as failed instead of promoting it to submitted. Not
28
+ // retryable — the same idempotency key can only return that record.
29
+ coord.applyOutcome(row.slotId, row.targetId, {
30
+ kind: 'failed',
31
+ retryable: false,
32
+ error: `downstream reported terminal status ${ack.remoteStatus} (review ${ack.remoteId ?? 'unknown'})`,
33
+ });
34
+ return true;
35
+ }
36
+ // accepted / idempotent_replay: a confirmed ACK is the sole path to submitted.
37
+ coord.markDelivered(row.slotId, row.targetId, row.pixivId, row.workType);
38
+ return true;
39
+ }
40
+ //# sourceMappingURL=settleDeliveryTerminal.js.map
@@ -128,6 +128,9 @@ class DownloadManager {
128
128
  this.novelDownloader = new NovelDownloader_1.NovelDownloader(client, database, fileService, database);
129
129
  this.planner = new DownloadPlanner_1.DownloadPlanner(database, {
130
130
  deliveredIds: (target, type, ids) => this.deliveryService.deliveredIds(target, type, ids),
131
+ // CANDIDATE SELECTION dedupe: also treats a work whose review submission is
132
+ // still PENDING as taken, so it is skipped instead of submitted again.
133
+ submittedIds: (target, type, ids) => this.deliveryService.submittedIds(target, type, ids),
131
134
  // Durable duplicate history handed in by the caller (the batch runner asks
132
135
  // the control plane for it). Independent of any local delivery target, so it
133
136
  // also works for a shadow run that delivers nowhere.
@@ -137,7 +140,10 @@ class DownloadManager {
137
140
  return new Set();
138
141
  return new Set(ids.filter((id) => known.has(id)));
139
142
  },
140
- });
143
+ // Global bounded candidate-scan window (`download.candidateScanLimit`).
144
+ // Per-target `candidateScanLimit` overrides it. Without this the pipeline
145
+ // would only ever see as many candidates as the target's own `limit`.
146
+ }, config.download?.candidateScanLimit);
141
147
  this.executor = new DownloadExecutor_1.DownloadExecutor();
142
148
  const downloadConfig = config.download ?? {};
143
149
  const maxRetries = downloadConfig.maxRetries ?? 3;
@@ -166,6 +172,11 @@ class DownloadManager {
166
172
  : config.storage?.databasePath, config.download?.requestDelay ?? 500, this.abortController.signal);
167
173
  this.illustrationHandler = new IllustrationTargetHandler_1.IllustrationTargetHandler(client, database, this.rankingService, this.illustrationDownloader, this.pipeline, topicFactory, this.deliveryService);
168
174
  this.novelHandler = new NovelTargetHandler_1.NovelTargetHandler(client, database, this.rankingService, this.pipeline, this.novelDownloader, topicFactory, this.deliveryService);
175
+ // The FETCH stage needs the same bound the planner applies to the attempt
176
+ // window, or a `limit: 1` target would ask the ranking API for one work and
177
+ // leave the bounded scan nothing to scan.
178
+ this.illustrationHandler.setDefaultCandidateScanLimit(config.download?.candidateScanLimit);
179
+ this.novelHandler.setDefaultCandidateScanLimit(config.download?.candidateScanLimit);
169
180
  }
170
181
  setProgressCallback(callback) {
171
182
  this.progressReporter.setCallback(callback);
@@ -18,6 +18,29 @@ export declare class IllustrationTargetHandler {
18
18
  private readonly deliveryService?;
19
19
  /** Outcomes produced during this handle() call (deliveries + terminal non-matches). */
20
20
  private outcomes;
21
+ /**
22
+ * Candidate scan of the last pipeline.run() of this handle() call: how many
23
+ * candidates were attempted, which were skipped and why, and whether any
24
+ * job-level outage appeared. This is what turns an empty result into an
25
+ * explicit verdict instead of an ambiguous "completed".
26
+ */
27
+ private scan;
28
+ /**
29
+ * Global candidate-scan bound (`download.candidateScanLimit`), supplied by
30
+ * DownloadManager. This is what lets the FETCH stage ask for more than one
31
+ * candidate: a scheduled one-post-per-slot target has `limit: 1`, and asking
32
+ * the ranking API for exactly one work is the reason a single duplicate used
33
+ * to be unfixable downstream.
34
+ */
35
+ private defaultCandidateScanLimit?;
36
+ /** Publish the global candidate-scan bound for the fetch stage. */
37
+ setDefaultCandidateScanLimit(limit: number | undefined): void;
38
+ /**
39
+ * How many candidates to FETCH so the bounded scan has something to choose
40
+ * from. Same rule the planner applies to the attempt window, so fetch and
41
+ * scan agree on one bound.
42
+ */
43
+ private candidateFetchLimit;
21
44
  /**
22
45
  * Cell identity for this handle() call. Set only for a single-work cell of a
23
46
  * scheduled occurrence; null for ad-hoc runs and N-works-per-run targets, which
@@ -26,7 +49,15 @@ export declare class IllustrationTargetHandler {
26
49
  private execution;
27
50
  constructor(client: IPixivClient, database: IDatabase, rankingService: RankingService, illustrationDownloader: IllustrationDownloader, pipeline: DownloadPipeline, topicPipelineFactory?: TopicPipelineFactory | undefined, deliveryService?: DeliveryService | undefined);
28
51
  handle(target: TargetConfig, execution?: TargetExecutionContext): Promise<TargetOutcome>;
29
- /** Reduce the outcomes collected while processing one target to one cell result. */
52
+ /**
53
+ * Reduce the outcomes collected while processing one target to one cell
54
+ * result, including the bounded-scan bookkeeping.
55
+ *
56
+ * A `duplicate` is deliberately NOT a target verdict here: a duplicate is a
57
+ * CANDIDATE problem (skip it and try the next candidate), which is what the
58
+ * scan already did. Returning it as the target outcome is the bug that made a
59
+ * scheduled slot report success after submitting nothing.
60
+ */
30
61
  private summarize;
31
62
  private classifyError;
32
63
  private fetchIllustrations;
@@ -56,6 +87,11 @@ export declare class IllustrationTargetHandler {
56
87
  /**
57
88
  * Process ONE candidate work for this cell.
58
89
  *
90
+ * Returns what happened to THIS candidate, so the pipeline can advance to the
91
+ * next one when it was unusable. Throwing is reserved for JOB-level failures
92
+ * (dead database / dead token / dead delivery provider): a candidate-level
93
+ * failure is reported as a `skipped` attempt and never as a job verdict.
94
+ *
59
95
  * The cell is bound to `illust` BEFORE any side effect: it is the binding, not
60
96
  * the candidate list, that decides what a later recovery resumes. The binding
61
97
  * is only rolled back when the attempt produced no artifact at all, so in-run
@@ -67,6 +103,11 @@ export declare class IllustrationTargetHandler {
67
103
  * delivery mode the DeliveryService creates the durable intent atomically and
68
104
  * the result is 'delivery_pending' (NOT submitted — the OutboxWorker confirms
69
105
  * the ACK). Persistent/download-only runs are 'stored'.
106
+ *
107
+ * An ALREADY-DELIVERED work is returned as a candidate SKIP, never as a
108
+ * target outcome: the run must try the next candidate instead of ending the
109
+ * slot as a `duplicate`. The delivery idempotency ledger is what makes the
110
+ * second concurrent worker lose this race instead of double-submitting.
70
111
  */
71
112
  private recordArtifactOutcome;
72
113
  private executionContextFields;