pixivflow 2.19.1 → 2.19.3

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 (43) hide show
  1. package/dist/commands/ExecuteSlotCommand.js +1 -1
  2. package/dist/commands/SchedulerCommand.js +3 -1
  3. package/dist/commands/SchedulerRunOnceCommand.js +1 -1
  4. package/dist/commands/scheduler-runtime.d.ts +31 -2
  5. package/dist/commands/scheduler-runtime.js +63 -9
  6. package/dist/delivery/DeliveryLedgerPort.d.ts +14 -0
  7. package/dist/delivery/DeliveryLedgerPort.js +43 -0
  8. package/dist/delivery/OutboxWorker.d.ts +2 -0
  9. package/dist/delivery/OutboxWorker.js +25 -13
  10. package/dist/delivery/errorClass.d.ts +1 -1
  11. package/dist/delivery/errorClass.js +7 -0
  12. package/dist/download/DownloadManager.d.ts +5 -0
  13. package/dist/download/DownloadManager.js +12 -2
  14. package/dist/download/handlers/IllustrationTargetHandler.d.ts +27 -1
  15. package/dist/download/handlers/IllustrationTargetHandler.js +105 -7
  16. package/dist/download/handlers/NovelTargetHandler.d.ts +27 -1
  17. package/dist/download/handlers/NovelTargetHandler.js +107 -3
  18. package/dist/notification/NotificationPolicy.d.ts +11 -0
  19. package/dist/notification/NotificationPolicy.js +22 -5
  20. package/dist/package.json +1 -1
  21. package/dist/pixiv-client/TargetSearchRunner.js +24 -8
  22. package/dist/scheduler/SlotCoordinator.d.ts +74 -1
  23. package/dist/scheduler/SlotCoordinator.js +108 -1
  24. package/dist/scheduler/WorkIdentity.d.ts +49 -0
  25. package/dist/scheduler/WorkIdentity.js +31 -0
  26. package/dist/storage/repositories/DeliveryRepository.d.ts +9 -0
  27. package/dist/storage/repositories/DeliveryRepository.js +16 -0
  28. package/dist/storage/repositories/OutboxRepository.d.ts +15 -0
  29. package/dist/storage/repositories/OutboxRepository.js +31 -0
  30. package/dist/storage/repositories/SlotRepository.d.ts +27 -0
  31. package/dist/storage/repositories/SlotRepository.js +45 -0
  32. package/dist/topic/TopicPipeline.js +4 -0
  33. package/dist/utils/errors.d.ts +9 -0
  34. package/dist/utils/errors.js +14 -1
  35. package/dist/version.js +1 -1
  36. package/dist/webui/package.json +1 -1
  37. package/node_modules/@redtidev/pixiv-client/dist/transport/transport.d.ts +9 -0
  38. package/node_modules/@redtidev/pixiv-client/dist/transport/transport.d.ts.map +1 -1
  39. package/node_modules/@redtidev/pixiv-client/dist/transport/transport.js +66 -14
  40. package/node_modules/@redtidev/pixiv-client/dist/transport/transport.js.map +1 -1
  41. package/node_modules/@redtidev/pixiv-client/src/transport/__tests__/transport-cancellation.test.ts +257 -0
  42. package/node_modules/@redtidev/pixiv-client/src/transport/transport.ts +81 -18
  43. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { DownloadPipeline } from '../pipeline/DownloadPipeline';
6
6
  import { NovelDownloader } from '../NovelDownloader';
7
7
  import { DeliveryService } from '../../delivery/DeliveryService';
8
8
  import { TargetOutcome } from '../../scheduler/TargetOutcome';
9
+ import { TargetExecutionContext } from '../../scheduler/WorkIdentity';
9
10
  import type { TopicPipelineFactory } from '../../topic/createTopicPipeline';
10
11
  export declare class NovelTargetHandler {
11
12
  private readonly client;
@@ -16,8 +17,14 @@ export declare class NovelTargetHandler {
16
17
  private readonly topicPipelineFactory?;
17
18
  private readonly deliveryService?;
18
19
  private outcomes;
20
+ /**
21
+ * Cell identity for this handle() call. Set only for a single-work cell of a
22
+ * scheduled occurrence; null for ad-hoc runs and N-works-per-run targets, which
23
+ * have no single (slotId,targetId) -> workId identity to honour.
24
+ */
25
+ private execution;
19
26
  constructor(client: IPixivClient, database: IDatabase, rankingService: RankingService, pipeline: DownloadPipeline, novelDownloader: NovelDownloader, topicPipelineFactory?: TopicPipelineFactory | undefined, deliveryService?: DeliveryService | undefined);
20
- handle(target: TargetConfig): Promise<TargetOutcome>;
27
+ handle(target: TargetConfig, execution?: TargetExecutionContext): Promise<TargetOutcome>;
21
28
  private summarize;
22
29
  private classifyError;
23
30
  private fetchNovels;
@@ -34,6 +41,25 @@ export declare class NovelTargetHandler {
34
41
  private handleUserNovels;
35
42
  private sortByPopularityAndLog;
36
43
  private logError;
44
+ /**
45
+ * Continue the work this cell ALREADY owns. Selection is deliberately skipped:
46
+ * no search, no ranking, no topic expansion, no backfill pool, and no
47
+ * "already downloaded / already delivered" exclusion — the cell's own work must
48
+ * never be filtered out of its own recovery.
49
+ *
50
+ * A locked work that is permanently gone is a terminal failure of THIS logical
51
+ * item (`LOCKED_WORK_UNAVAILABLE`). It is never silently replaced by another
52
+ * candidate; that would mutate the item's identity behind the operator's back.
53
+ */
54
+ private recoverLockedWork;
55
+ /**
56
+ * Process ONE candidate work for this cell.
57
+ *
58
+ * The cell is bound to `novel` BEFORE any side effect: it is the binding, not
59
+ * the candidate list, that decides what a later recovery resumes. The binding
60
+ * is only rolled back when the attempt produced no artifact at all, so in-run
61
+ * backfill still works for a cell that has committed to nothing.
62
+ */
37
63
  private downloadAndDeliver;
38
64
  }
39
65
  //# sourceMappingURL=NovelTargetHandler.d.ts.map
@@ -5,6 +5,7 @@ const logger_1 = require("../../logger");
5
5
  const errors_1 = require("../../utils/errors");
6
6
  const pixiv_date_utils_1 = require("../../utils/pixiv-date-utils");
7
7
  const pixiv_utils_1 = require("../../utils/pixiv-utils");
8
+ const WorkIdentity_1 = require("../../scheduler/WorkIdentity");
8
9
  const target_label_1 = require("../../utils/target-label");
9
10
  class NovelTargetHandler {
10
11
  client;
@@ -15,6 +16,12 @@ class NovelTargetHandler {
15
16
  topicPipelineFactory;
16
17
  deliveryService;
17
18
  outcomes = [];
19
+ /**
20
+ * Cell identity for this handle() call. Set only for a single-work cell of a
21
+ * scheduled occurrence; null for ad-hoc runs and N-works-per-run targets, which
22
+ * have no single (slotId,targetId) -> workId identity to honour.
23
+ */
24
+ execution = null;
18
25
  constructor(client, database, rankingService, pipeline, novelDownloader, topicPipelineFactory, deliveryService) {
19
26
  this.client = client;
20
27
  this.database = database;
@@ -24,8 +31,17 @@ class NovelTargetHandler {
24
31
  this.topicPipelineFactory = topicPipelineFactory;
25
32
  this.deliveryService = deliveryService;
26
33
  }
27
- async handle(target) {
34
+ async handle(target, execution) {
28
35
  this.outcomes = [];
36
+ this.execution = execution && (0, WorkIdentity_1.isSingleWorkCell)(target) ? execution : null;
37
+ // A cell that already owns a work is in RECOVERY, not in a new selection.
38
+ // Crash/shutdown recovery is not an intentional second run: running the
39
+ // candidate pipeline here would re-rank and could bind this logical item to a
40
+ // different work than the one it already committed to.
41
+ if (this.execution?.lockedWorkId) {
42
+ await this.recoverLockedWork(target, this.execution.lockedWorkId);
43
+ return this.summarize();
44
+ }
29
45
  if (target.novelId !== undefined && target.novelId !== null && target.novelId !== '') {
30
46
  const novelNum = typeof target.novelId === 'number' ? target.novelId : Number(target.novelId);
31
47
  if (!Number.isFinite(novelNum)) {
@@ -410,10 +426,98 @@ class NovelTargetHandler {
410
426
  stack: error instanceof Error ? error.stack : undefined,
411
427
  });
412
428
  }
429
+ /**
430
+ * Continue the work this cell ALREADY owns. Selection is deliberately skipped:
431
+ * no search, no ranking, no topic expansion, no backfill pool, and no
432
+ * "already downloaded / already delivered" exclusion — the cell's own work must
433
+ * never be filtered out of its own recovery.
434
+ *
435
+ * A locked work that is permanently gone is a terminal failure of THIS logical
436
+ * item (`LOCKED_WORK_UNAVAILABLE`). It is never silently replaced by another
437
+ * candidate; that would mutate the item's identity behind the operator's back.
438
+ */
439
+ async recoverLockedWork(target, lockedWorkId) {
440
+ const displayTag = (0, target_label_1.getTargetLabel)(target);
441
+ logger_1.logger.info(`Recovering locked work ${lockedWorkId} for ${displayTag}; candidate selection is skipped`, {
442
+ slotId: this.execution?.slotId,
443
+ targetId: this.execution?.targetId,
444
+ lockedWorkId,
445
+ });
446
+ const novelId = Number(lockedWorkId);
447
+ if (!Number.isFinite(novelId)) {
448
+ this.outcomes.push({
449
+ kind: 'failed',
450
+ retryable: false,
451
+ error: `LOCKED_WORK_UNAVAILABLE: cell work id "${lockedWorkId}" is not a valid novel id`,
452
+ });
453
+ return;
454
+ }
455
+ try {
456
+ const detail = await this.client.getNovelDetail(novelId);
457
+ const novel = {
458
+ id: detail.id,
459
+ title: detail.title,
460
+ user: detail.user,
461
+ create_date: detail.create_date,
462
+ };
463
+ await this.downloadAndDeliver(novel, `novel-${novelId}`, target);
464
+ }
465
+ catch (error) {
466
+ const message = error instanceof Error ? error.message : String(error);
467
+ const retryable = (0, errors_1.isRetryableNetworkError)(error);
468
+ this.logError(error, `Failed to recover locked novel ${lockedWorkId}`);
469
+ this.outcomes.push({
470
+ kind: 'failed',
471
+ retryable,
472
+ error: retryable
473
+ ? `locked work ${lockedWorkId} could not be fetched (will retry the SAME work): ${message}`
474
+ : `LOCKED_WORK_UNAVAILABLE: locked work ${lockedWorkId} is no longer fetchable: ${message}`,
475
+ });
476
+ }
477
+ }
478
+ /**
479
+ * Process ONE candidate work for this cell.
480
+ *
481
+ * The cell is bound to `novel` BEFORE any side effect: it is the binding, not
482
+ * the candidate list, that decides what a later recovery resumes. The binding
483
+ * is only rolled back when the attempt produced no artifact at all, so in-run
484
+ * backfill still works for a cell that has committed to nothing.
485
+ */
413
486
  async downloadAndDeliver(novel, tag, target) {
414
- const artifact = await this.novelDownloader.download(novel, tag, target);
415
- if (!artifact)
487
+ const execution = this.execution;
488
+ const workId = String(novel.id);
489
+ // Recovery continues a work the cell already bound; it must never be released.
490
+ const recovering = Boolean(execution?.lockedWorkId);
491
+ if (execution && !recovering) {
492
+ const binding = execution.bind(workId, 'novel');
493
+ if (!binding.won) {
494
+ // Another writer elected a different work for this cell. First selection
495
+ // is authoritative: never process a work the cell does not own.
496
+ logger_1.logger.warn(`Cell is bound to work ${binding.workId}; declining to select ${workId}`, {
497
+ slotId: execution.slotId,
498
+ targetId: execution.targetId,
499
+ boundWorkId: binding.workId,
500
+ });
501
+ return;
502
+ }
503
+ }
504
+ let artifact;
505
+ try {
506
+ artifact = await this.novelDownloader.download(novel, tag, target);
507
+ }
508
+ catch (error) {
509
+ // Nothing was persisted, so the cell may still pick another candidate.
510
+ if (execution && !recovering)
511
+ execution.release(workId);
512
+ throw error;
513
+ }
514
+ if (!artifact) {
515
+ if (execution && !recovering)
516
+ execution.release(workId);
416
517
  return;
518
+ }
519
+ // Committed: the artifact is durable and this work now defines the cell. Even
520
+ // if the delivery below throws, recovery must resume THIS work — never release.
417
521
  const isDelivery = target.storageMode === 'cache' && target.delivery?.target?.trim();
418
522
  if (!isDelivery || !this.deliveryService) {
419
523
  this.outcomes.push({ kind: 'stored', workId: artifact.pixivId, workType: artifact.type });
@@ -16,6 +16,17 @@ export declare class NotificationPolicy {
16
16
  private readonly config;
17
17
  constructor(database: Database, config: StandaloneConfig);
18
18
  private targetName;
19
+ /**
20
+ * Delivery targets that can actually RECEIVE an operational notification.
21
+ *
22
+ * `delivery.target` names a SUBMISSION endpoint (multipart POST of a work).
23
+ * Only targets that also declare a `notificationUrl` accept notifications —
24
+ * the same rule `config/validation.ts` enforces for `noMatchPolicy.notify`
25
+ * and that `docs/CONFIG.md` documents. A submission endpoint without one
26
+ * rejects every notification, so enqueuing to it would only grow an outbox
27
+ * row that can never be delivered.
28
+ */
29
+ private notifiableTargets;
19
30
  /** Stable slot-scoped keys (two occurrences on one date never collide). */
20
31
  static keys: {
21
32
  noMatch: (slotId: string, targetId: string) => string;
@@ -21,6 +21,23 @@ class NotificationPolicy {
21
21
  targetName(target) {
22
22
  return target.delivery?.target?.trim() || null;
23
23
  }
24
+ /**
25
+ * Delivery targets that can actually RECEIVE an operational notification.
26
+ *
27
+ * `delivery.target` names a SUBMISSION endpoint (multipart POST of a work).
28
+ * Only targets that also declare a `notificationUrl` accept notifications —
29
+ * the same rule `config/validation.ts` enforces for `noMatchPolicy.notify`
30
+ * and that `docs/CONFIG.md` documents. A submission endpoint without one
31
+ * rejects every notification, so enqueuing to it would only grow an outbox
32
+ * row that can never be delivered.
33
+ */
34
+ notifiableTargets() {
35
+ const targets = this.config.delivery?.targets ?? {};
36
+ return new Set(Object.keys(targets).filter((name) => {
37
+ const target = targets[name];
38
+ return target?.type === 'httpMultipart' && Boolean(target.notificationUrl?.trim());
39
+ }));
40
+ }
24
41
  /** Stable slot-scoped keys (two occurrences on one date never collide). */
25
42
  static keys = {
26
43
  noMatch: (slotId, targetId) => `notification:${slotId}:${targetId}:no-candidate`,
@@ -32,6 +49,10 @@ class NotificationPolicy {
32
49
  const name = this.targetName(target);
33
50
  if (!name)
34
51
  return;
52
+ // No notifiable endpoint configured: drop the notification instead of
53
+ // enqueuing it against a submission target that will reject it forever.
54
+ if (!this.notifiableTargets().has(name))
55
+ return;
35
56
  const label = target.id || target.filterTag || target.tag || target.type;
36
57
  if (outcome.kind === 'no_candidate' && target.noMatchPolicy?.notify === true) {
37
58
  this.send(name, NotificationPolicy.keys.noMatch(slotId, target.id ?? label), [
@@ -53,11 +74,7 @@ class NotificationPolicy {
53
74
  }
54
75
  /** One consolidated summary per slot, delivered to every notifying target's endpoint. */
55
76
  sendSlotSummary(slot, schedule, rows) {
56
- const targets = this.config.delivery?.targets ?? {};
57
- const notifiable = new Set(Object.keys(targets).filter((n) => {
58
- const target = targets[n];
59
- return target?.type === 'httpMultipart' && Boolean(target.notificationUrl?.trim());
60
- }));
77
+ const notifiable = this.notifiableTargets();
61
78
  if (notifiable.size === 0 || rows.length === 0)
62
79
  return;
63
80
  const icon = (s) => s === 'submitted' ? '✅' : s === 'no_candidate' ? '⚠️' : s === 'duplicate' ? '♱' : s === 'delivery_pending' ? '🕓' : '❌';
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "type": "commonjs",
3
3
  "name": "pixivflow",
4
- "version": "2.19.1",
4
+ "version": "2.19.3",
5
5
  "private": true
6
6
  }
@@ -56,10 +56,10 @@ class TargetSearchRunner {
56
56
  return target.limit ? sorted.slice(0, target.limit) : sorted;
57
57
  }
58
58
  async searchSingleIllust(target, tag, requestDelayMs, signal) {
59
- return this.searchWithPagination(target, tag, requestDelayMs, (options) => this.kit.illustrations.searchPage(options), (t, g) => (0, query_mapper_1.mapTargetToIllustQuery)({ ...t, tag: g }), signal);
59
+ return this.searchWithPagination(target, tag, requestDelayMs, (options, cursor) => this.kit.illustrations.searchPage(options, cursor), (t, g) => (0, query_mapper_1.mapTargetToIllustQuery)({ ...t, tag: g }), signal);
60
60
  }
61
61
  async searchSingleNovel(target, tag, requestDelayMs, signal) {
62
- return this.searchWithPagination(target, tag, requestDelayMs, (options) => this.kit.novels.searchPage(options), (t, g) => (0, query_mapper_1.mapTargetToNovelQuery)({ ...t, tag: g }), signal);
62
+ return this.searchWithPagination(target, tag, requestDelayMs, (options, cursor) => this.kit.novels.searchPage(options, cursor), (t, g) => (0, query_mapper_1.mapTargetToNovelQuery)({ ...t, tag: g }), signal);
63
63
  }
64
64
  /**
65
65
  * Date-aware pagination. Mirrors the legacy SearchService semantics:
@@ -96,23 +96,39 @@ class TargetSearchRunner {
96
96
  let cursor = null;
97
97
  let pageCount = 0;
98
98
  let shouldStop = false;
99
+ // Progress invariant: every iteration must terminate, advance to a cursor we
100
+ // have never fetched, or fail explicitly. A cursor that repeats (same `next`
101
+ // twice, or an A -> B -> A cycle) proves the upstream adapter is not making
102
+ // progress; re-requesting would spin forever, so we refuse instead.
103
+ const seenCursors = new Set();
99
104
  while ((!fetchLimit || results.length < fetchLimit) && !shouldStop) {
100
105
  (0, errors_1.throwIfAborted)(signal, 'search cancelled');
101
106
  pageCount++;
102
107
  // Dates are intentionally filtered CLIENT-side below (legacy behavior;
103
108
  // PixivFlow needs the early-stop walk on create_date).
109
+ //
110
+ // The cursor must travel as searchPage's SECOND POSITIONAL argument: the
111
+ // kit reads `options.cursor` only inside its own `search()`. Putting it in
112
+ // the options object left the cursor permanently null, so page 1 (the
113
+ // newest works) was re-fetched on every iteration and a search that found
114
+ // no in-range work on that page never terminated.
104
115
  const page = await fetchOne({
105
116
  word: base.word,
106
117
  sort: base.sort,
107
118
  searchTarget: base.searchTarget,
108
119
  includeR18: base.includeR18,
109
- cursor,
110
- // Threaded into the kit transport, which combines it with its per-request
111
- // timeout and honours it in retry back-off. Without this the pager could
112
- // stay blocked in a single hung request for the rest of the run.
113
120
  signal,
114
- });
115
- cursor = page.next;
121
+ }, cursor);
122
+ const nextCursor = page.next;
123
+ if (nextCursor !== null) {
124
+ if (nextCursor === cursor || seenCursors.has(nextCursor)) {
125
+ throw new errors_1.PaginationError(`Search pager for tag "${tag}" did not advance: page ${pageCount} returned a cursor that ` +
126
+ (nextCursor === cursor ? 'equals the one just fetched' : 'was already fetched') +
127
+ `. Refusing to re-request it indefinitely.`);
128
+ }
129
+ seenCursors.add(nextCursor);
130
+ }
131
+ cursor = nextCursor;
116
132
  for (const item of page.items) {
117
133
  const decision = this.filterItemByDate(item, target, startDate, endDate, sortMode);
118
134
  if (decision.shouldStop) {
@@ -3,6 +3,7 @@ import { Database } from '../storage/Database';
3
3
  import { CellStatus, SlotItemRecord, SlotRecord, SlotStatus } from '../storage/repositories/SlotRepository';
4
4
  import { TriggerSource } from './OccurrenceResolver';
5
5
  import { TargetOutcome } from './TargetOutcome';
6
+ import { TargetExecutionContext, WorkBinding } from './WorkIdentity';
6
7
  /**
7
8
  * Execution-lease TTL and heartbeat cadence.
8
9
  *
@@ -54,6 +55,40 @@ export interface SlotResolveResult {
54
55
  error?: string;
55
56
  status?: number;
56
57
  }
58
+ /**
59
+ * What a `delivery_pending` cell still owes downstream, read from the durable
60
+ * delivery ledger.
61
+ *
62
+ * - `live` : a non-terminal intent still owns the cell — the outbox is
63
+ * retrying THAT work toward a terminal ACK.
64
+ * - `confirmed` : the ACK already landed but the cell promotion was lost.
65
+ * - `lost` : the intent is terminally failed; nobody will converge it.
66
+ * - `unknown` : no delivery fact for this cell (not a delivery cell).
67
+ */
68
+ export type CellDeliveryState = {
69
+ kind: 'live';
70
+ } | {
71
+ kind: 'confirmed';
72
+ workId: string;
73
+ workType: string;
74
+ } | {
75
+ kind: 'lost';
76
+ reason: string;
77
+ } | {
78
+ kind: 'unknown';
79
+ };
80
+ /**
81
+ * Port for the delivery ledger, injected by the runtime. It lets the slot FSM
82
+ * hand a `delivery_pending` cell to whoever owns its delivery without this class
83
+ * learning anything about TelePost, bots or any hosting platform (see below).
84
+ */
85
+ export interface SchedulerDeliveryPort {
86
+ stateFor(input: {
87
+ deliveryTarget: string;
88
+ slotId: string;
89
+ targetId: string;
90
+ }): CellDeliveryState;
91
+ }
57
92
  /**
58
93
  * Owns the Schedule Slot ledger for one scheduler run. A Slot is one durable
59
94
  * execution occurrence of a Schedule (NOT a morning/evening row). It ensures
@@ -66,7 +101,8 @@ export interface SlotResolveResult {
66
101
  */
67
102
  export declare class SlotCoordinator {
68
103
  private readonly database;
69
- constructor(database: Database);
104
+ private readonly delivery?;
105
+ constructor(database: Database, delivery?: SchedulerDeliveryPort | undefined);
70
106
  /**
71
107
  * Resolve + validate the canonical occurrence for a trigger. Uses ONLY the
72
108
  * schedule's cron + timezone and the trigger instant (never a client-supplied
@@ -103,8 +139,33 @@ export declare class SlotCoordinator {
103
139
  target: TargetConfig;
104
140
  cell: SlotItemRecord;
105
141
  }[];
142
+ /**
143
+ * Build the per-target execution contexts for one run, so every handler
144
+ * receives the cell's durable identity instead of just a TargetConfig.
145
+ *
146
+ * This is the boundary where the identity used to be dropped (`pending` was
147
+ * reduced to `p.target`), which is what let a resume re-rank and re-point the
148
+ * logical item at a different work.
149
+ */
150
+ executionContextsFor(slotId: string, entries: {
151
+ target: TargetConfig;
152
+ cell: SlotItemRecord | null;
153
+ }[]): Map<string, TargetExecutionContext>;
106
154
  /** Lock the selected work for a cell (first selection wins; retries keep it). */
107
155
  lockWork(slotId: string, targetId: string, workId: string, workType: string): void;
156
+ /**
157
+ * CAS-bind a cell to the work a handler is about to process. Returns the
158
+ * authoritative binding: when another worker already elected a different work,
159
+ * `won` is false and the caller MUST continue with the returned `workId`
160
+ * instead of its own candidate.
161
+ */
162
+ lockWorkCas(slotId: string, targetId: string, workId: string, workType: string): WorkBinding;
163
+ /**
164
+ * Release a provisional binding whose work produced no local artifact, so the
165
+ * next candidate of an UNBOUND cell can be tried. Refused once anything was
166
+ * committed (see SlotRepository.releaseCellWork).
167
+ */
168
+ releaseWorkCas(slotId: string, targetId: string, workId: string): void;
108
169
  /** Record a cell's terminal state from the download/delivery outcome. */
109
170
  markCell(slotId: string, targetId: string, status: CellStatus, error?: string): void;
110
171
  /**
@@ -116,6 +177,18 @@ export declare class SlotCoordinator {
116
177
  applyOutcome(slotId: string, targetId: string, outcome: TargetOutcome): void;
117
178
  /** Promote a delivery_pending cell to submitted from a confirmed ACK. */
118
179
  markDelivered(slotId: string, targetId: string, workId: string, workType: string): void;
180
+ /**
181
+ * Decide what a `delivery_pending` cell owes, from the durable delivery
182
+ * ledger, and converge the FSM accordingly. Returns true when the scheduler
183
+ * must NOT run the target handler for it.
184
+ *
185
+ * Once an intent is durable the outbox owns the delivery: re-running selection
186
+ * could only produce a DIFFERENT work for the same logical item (and a second
187
+ * delivery, since the delivery idempotency key is work-scoped). A terminally
188
+ * failed delivery is converged explicitly — never "repaired" by picking
189
+ * another work behind the operator's back.
190
+ */
191
+ private settlePendingDelivery;
119
192
  private safeTransition;
120
193
  /** Cross-process execution lease. Duplicate triggers converge, never parallel-run. */
121
194
  claimRunLease(slotId: string, owner: string, leaseMs: number): boolean;
@@ -4,6 +4,7 @@ exports.SlotCoordinator = exports.SLOT_HEARTBEAT_MS = exports.SLOT_LEASE_TTL_MS
4
4
  exports.timezoneForSchedule = timezoneForSchedule;
5
5
  const logger_1 = require("../logger");
6
6
  const OccurrenceResolver_1 = require("./OccurrenceResolver");
7
+ const WorkIdentity_1 = require("./WorkIdentity");
7
8
  /**
8
9
  * Execution-lease TTL and heartbeat cadence.
9
10
  *
@@ -17,6 +18,13 @@ const OccurrenceResolver_1 = require("./OccurrenceResolver");
17
18
  */
18
19
  exports.SLOT_LEASE_TTL_MS = 3 * 60 * 1000;
19
20
  exports.SLOT_HEARTBEAT_MS = 30 * 1000;
21
+ /** The delivery channel a scheduled cell publishes to (null = download-only). */
22
+ function deliveryTargetOf(target) {
23
+ if (target.storageMode !== 'cache')
24
+ return null;
25
+ const name = target.delivery?.target;
26
+ return typeof name === 'string' && name.trim() ? name.trim() : null;
27
+ }
20
28
  /**
21
29
  * Owns the Schedule Slot ledger for one scheduler run. A Slot is one durable
22
30
  * execution occurrence of a Schedule (NOT a morning/evening row). It ensures
@@ -29,8 +37,10 @@ exports.SLOT_HEARTBEAT_MS = 30 * 1000;
29
37
  */
30
38
  class SlotCoordinator {
31
39
  database;
32
- constructor(database) {
40
+ delivery;
41
+ constructor(database, delivery) {
33
42
  this.database = database;
43
+ this.delivery = delivery;
34
44
  }
35
45
  /**
36
46
  * Resolve + validate the canonical occurrence for a trigger. Uses ONLY the
@@ -133,15 +143,65 @@ class SlotCoordinator {
133
143
  continue;
134
144
  if (cell.status === 'submitted' || cell.status === 'no_candidate')
135
145
  continue;
146
+ // A cell whose work already has a durable delivery intent is NOT the
147
+ // scheduler's to re-run: the OutboxWorker retries the SAME work to a
148
+ // terminal ACK. Re-selecting here is what let recovery stop pointing at the
149
+ // cell's own work (or enqueue a second one), so it is delegated instead.
150
+ if (cell.status === 'delivery_pending' && (0, WorkIdentity_1.isSingleWorkCell)(target) && this.settlePendingDelivery(slotId, target)) {
151
+ continue;
152
+ }
136
153
  out.push({ target, cell });
137
154
  }
138
155
  return out;
139
156
  }
157
+ /**
158
+ * Build the per-target execution contexts for one run, so every handler
159
+ * receives the cell's durable identity instead of just a TargetConfig.
160
+ *
161
+ * This is the boundary where the identity used to be dropped (`pending` was
162
+ * reduced to `p.target`), which is what let a resume re-rank and re-point the
163
+ * logical item at a different work.
164
+ */
165
+ executionContextsFor(slotId, entries) {
166
+ const contexts = new Map();
167
+ for (const { target, cell } of entries) {
168
+ if (!target.id || !cell)
169
+ continue;
170
+ const targetId = target.id;
171
+ contexts.set(targetId, {
172
+ slotId,
173
+ targetId,
174
+ lockedWorkId: cell.workId,
175
+ bind: (workId, workType) => this.lockWorkCas(slotId, targetId, workId, workType),
176
+ release: (workId) => this.releaseWorkCas(slotId, targetId, workId),
177
+ });
178
+ }
179
+ return contexts;
180
+ }
140
181
  /** Lock the selected work for a cell (first selection wins; retries keep it). */
141
182
  lockWork(slotId, targetId, workId, workType) {
142
183
  this.database.slots.ensureCell(slotId, targetId, workType);
143
184
  this.database.slots.lockCellWork(slotId, targetId, workId, workType);
144
185
  }
186
+ /**
187
+ * CAS-bind a cell to the work a handler is about to process. Returns the
188
+ * authoritative binding: when another worker already elected a different work,
189
+ * `won` is false and the caller MUST continue with the returned `workId`
190
+ * instead of its own candidate.
191
+ */
192
+ lockWorkCas(slotId, targetId, workId, workType) {
193
+ this.database.slots.ensureCell(slotId, targetId, workType);
194
+ const { cell, won } = this.database.slots.tryLockCellWork(slotId, targetId, workId, workType);
195
+ return { workId: cell.workId ?? workId, won };
196
+ }
197
+ /**
198
+ * Release a provisional binding whose work produced no local artifact, so the
199
+ * next candidate of an UNBOUND cell can be tried. Refused once anything was
200
+ * committed (see SlotRepository.releaseCellWork).
201
+ */
202
+ releaseWorkCas(slotId, targetId, workId) {
203
+ this.database.slots.releaseCellWork(slotId, targetId, workId);
204
+ }
145
205
  /** Record a cell's terminal state from the download/delivery outcome. */
146
206
  markCell(slotId, targetId, status, error) {
147
207
  const cell = this.database.slots.getCell(slotId, targetId);
@@ -203,6 +263,53 @@ class SlotCoordinator {
203
263
  this.database.slots.lockCellWork(slotId, targetId, workId, workType);
204
264
  this.safeTransition(slotId, targetId, 'submitted');
205
265
  }
266
+ /**
267
+ * Decide what a `delivery_pending` cell owes, from the durable delivery
268
+ * ledger, and converge the FSM accordingly. Returns true when the scheduler
269
+ * must NOT run the target handler for it.
270
+ *
271
+ * Once an intent is durable the outbox owns the delivery: re-running selection
272
+ * could only produce a DIFFERENT work for the same logical item (and a second
273
+ * delivery, since the delivery idempotency key is work-scoped). A terminally
274
+ * failed delivery is converged explicitly — never "repaired" by picking
275
+ * another work behind the operator's back.
276
+ */
277
+ settlePendingDelivery(slotId, target) {
278
+ const deliveryTarget = deliveryTargetOf(target);
279
+ if (!deliveryTarget || !this.delivery || !target.id)
280
+ return false;
281
+ const state = this.delivery.stateFor({ deliveryTarget, slotId, targetId: target.id });
282
+ switch (state.kind) {
283
+ case 'live':
284
+ logger_1.logger.info('Cell delivery still owned by the outbox; selection not re-run', {
285
+ slot: slotId,
286
+ target: target.id,
287
+ deliveryTarget,
288
+ });
289
+ return true;
290
+ case 'confirmed':
291
+ // The ACK landed but the promotion was lost (crash between the delivery
292
+ // ledger write and the cell transition). Heal from the ledger — the work
293
+ // IS delivered, so re-selecting would post a second one.
294
+ logger_1.logger.info('Cell delivery already confirmed; promoting the cell from the ledger', {
295
+ slot: slotId,
296
+ target: target.id,
297
+ workId: state.workId,
298
+ });
299
+ this.markDelivered(slotId, target.id, state.workId, state.workType);
300
+ return true;
301
+ case 'lost':
302
+ logger_1.logger.warn('Cell delivery failed terminally; failing the cell instead of re-selecting', {
303
+ slot: slotId,
304
+ target: target.id,
305
+ reason: state.reason,
306
+ });
307
+ this.applyOutcome(slotId, target.id, { kind: 'failed', retryable: false, error: state.reason });
308
+ return true;
309
+ case 'unknown':
310
+ return false;
311
+ }
312
+ }
206
313
  safeTransition(slotId, targetId, next, error) {
207
314
  try {
208
315
  this.database.slots.transitionCell(slotId, targetId, next, error);
@@ -0,0 +1,49 @@
1
+ import { TargetConfig } from '../config';
2
+ /**
3
+ * Work identity for a scheduled cell.
4
+ *
5
+ * Invariant: (slotId, targetId) -> ONE stable workId. The first selection wins,
6
+ * and every later crash / shutdown / lease recovery continues with THAT work.
7
+ * Re-running candidate selection on resume silently re-points the logical item
8
+ * at a different work (A -> B): the item then reports success while A sits
9
+ * downloaded-but-never-delivered, and if A's delivery intent was already
10
+ * durable both A and B get posted under different idempotency identities.
11
+ */
12
+ export interface WorkBinding {
13
+ /** Authoritative work id for the cell after the attempt (the CAS winner). */
14
+ workId: string;
15
+ /** True when this caller established — or already held — the binding. */
16
+ won: boolean;
17
+ }
18
+ /**
19
+ * Per-cell execution context handed to a target handler, so the cell's identity
20
+ * travels with the work instead of being dropped at the dispatch boundary.
21
+ *
22
+ * `lockedWorkId` is an authoritative INPUT: when it is set, the handler must
23
+ * continue exactly that work and must not run ranking / topic selection /
24
+ * backfill / already-seen filtering. `bind()` is called for a candidate BEFORE
25
+ * any of its side effects, so a crash during the download can never resume the
26
+ * cell onto a different work.
27
+ */
28
+ export interface TargetExecutionContext {
29
+ readonly slotId: string;
30
+ readonly targetId: string;
31
+ /** The work this logical item is already bound to; null for a fresh cell. */
32
+ readonly lockedWorkId: string | null;
33
+ /** CAS-bind the cell to `workId`; returns the authoritative binding. */
34
+ bind(workId: string, workType: string): WorkBinding;
35
+ /** Roll back a provisional binding for a work that produced no artifact. */
36
+ release(workId: string): void;
37
+ }
38
+ /**
39
+ * True when one cell of this target selects at most ONE work per run — the only
40
+ * shape the (slotId, targetId) -> workId invariant can hold for.
41
+ *
42
+ * A target that intentionally pulls N works per run (limit > 1, a novel series,
43
+ * a user feed) owns N works inside a single cell, so pinning that cell to one
44
+ * locked id on resume would silently shrink the run to a single work. Those keep
45
+ * per-run candidate selection; the work-identity contract covers single-work
46
+ * cells, which is what a scheduled "one post per slot" target is.
47
+ */
48
+ export declare function isSingleWorkCell(target: TargetConfig): boolean;
49
+ //# sourceMappingURL=WorkIdentity.d.ts.map