pixivflow 3.2.0 → 3.3.0

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 (58) hide show
  1. package/README.md +26 -1
  2. package/dist/commands/SchedulerCommand.js +31 -51
  3. package/dist/commands/scheduler-runtime.js +47 -7
  4. package/dist/config/defaults.d.ts +2 -0
  5. package/dist/config/defaults.js +6 -0
  6. package/dist/config/types.d.ts +20 -0
  7. package/dist/config/validation.js +11 -0
  8. package/dist/delivery/DeliveryDispatcher.d.ts +14 -0
  9. package/dist/delivery/DeliveryDispatcher.js +14 -0
  10. package/dist/delivery/EventCallbackDelivery.d.ts +54 -0
  11. package/dist/delivery/EventCallbackDelivery.js +89 -0
  12. package/dist/delivery/OutboxWorker.d.ts +11 -0
  13. package/dist/delivery/OutboxWorker.js +51 -1
  14. package/dist/package.json +1 -1
  15. package/dist/scheduler/CandidateSearchParams.d.ts +76 -0
  16. package/dist/scheduler/CandidateSearchParams.js +94 -0
  17. package/dist/scheduler/JobCancellation.d.ts +36 -0
  18. package/dist/scheduler/JobCancellation.js +73 -0
  19. package/dist/scheduler/JobEventStream.d.ts +94 -0
  20. package/dist/scheduler/JobEventStream.js +251 -0
  21. package/dist/scheduler/JobFacade.d.ts +298 -0
  22. package/dist/scheduler/JobFacade.js +622 -0
  23. package/dist/scheduler/JobProjection.d.ts +67 -0
  24. package/dist/scheduler/JobProjection.js +32 -0
  25. package/dist/scheduler/JobView.d.ts +34 -0
  26. package/dist/scheduler/JobView.js +82 -0
  27. package/dist/scheduler/ManualJobAdmission.d.ts +126 -0
  28. package/dist/scheduler/ManualJobAdmission.js +310 -0
  29. package/dist/scheduler/ManualJobService.d.ts +57 -0
  30. package/dist/scheduler/ManualJobService.js +91 -0
  31. package/dist/scheduler/ManualRefetchAdapter.d.ts +26 -0
  32. package/dist/scheduler/ManualRefetchAdapter.js +41 -0
  33. package/dist/scheduler/MultiScheduleManager.d.ts +17 -0
  34. package/dist/scheduler/MultiScheduleManager.js +68 -6
  35. package/dist/scheduler/ProtocolErrors.d.ts +68 -0
  36. package/dist/scheduler/ProtocolErrors.js +94 -0
  37. package/dist/scheduler/ScheduleTriggerServer.d.ts +56 -7
  38. package/dist/scheduler/ScheduleTriggerServer.js +166 -1
  39. package/dist/scheduler/SlotBusinessStatus.d.ts +5 -1
  40. package/dist/scheduler/SlotBusinessStatus.js +6 -0
  41. package/dist/scheduler/SlotCoordinator.d.ts +49 -5
  42. package/dist/scheduler/SlotCoordinator.js +89 -22
  43. package/dist/scheduler/StallSweep.d.ts +65 -0
  44. package/dist/scheduler/StallSweep.js +105 -0
  45. package/dist/scheduler/TargetOutcome.d.ts +39 -1
  46. package/dist/scheduler/TargetOutcome.js +51 -1
  47. package/dist/scheduler/ledger-time.d.ts +23 -0
  48. package/dist/scheduler/ledger-time.js +37 -0
  49. package/dist/storage/DatabaseMigration.js +48 -0
  50. package/dist/storage/repositories/DeliveryRepository.d.ts +6 -0
  51. package/dist/storage/repositories/DeliveryRepository.js +13 -0
  52. package/dist/storage/repositories/OutboxRepository.d.ts +73 -1
  53. package/dist/storage/repositories/OutboxRepository.js +142 -0
  54. package/dist/storage/repositories/SlotRepository.d.ts +34 -0
  55. package/dist/storage/repositories/SlotRepository.js +61 -2
  56. package/dist/version.js +1 -1
  57. package/dist/webui/package.json +1 -1
  58. package/package.json +1 -1
@@ -9,6 +9,8 @@ exports.scheduleProvider = scheduleProvider;
9
9
  const express_1 = __importDefault(require("express"));
10
10
  const node_crypto_1 = require("node:crypto");
11
11
  const logger_1 = require("../logger");
12
+ const JobFacade_1 = require("./JobFacade");
13
+ const ProtocolErrors_1 = require("./ProtocolErrors");
12
14
  const version_1 = require("../version");
13
15
  const TRIGGER_OUTCOMES = {
14
16
  accepted: { event: 'schedule.trigger_accepted', httpStatus: 202, status: 'accepted', note: 'queued' },
@@ -93,6 +95,7 @@ class ScheduleTriggerServer {
93
95
  static resolveToken(configured) {
94
96
  return (configured ?? process.env.SCHEDULER_TRIGGER_TOKEN ?? '').trim() || undefined;
95
97
  }
98
+ /** `start` returns the listening server so tests/callers can read the port. */
96
99
  start(host, port) {
97
100
  const app = (0, express_1.default)();
98
101
  app.use(express_1.default.json());
@@ -108,6 +111,139 @@ class ScheduleTriggerServer {
108
111
  commit: version_1.BUILD.commit,
109
112
  });
110
113
  });
114
+ // ---------------------------------------------------------------------
115
+ // Generic job facade — Workflow Protocol v1 (Task -> Job).
116
+ //
117
+ // Same server, same bearer token as the manual-work family (`refetchAuth`):
118
+ // this is not a second HTTP system and not a second execution path. Both
119
+ // this surface and the legacy `/internal/targets/:targetId/refetch` shim
120
+ // funnel into ONE shared admission function, so identity, idempotency and
121
+ // the terminal state can never diverge between entry points.
122
+ //
123
+ // No `refetch*` vocabulary appears on this surface, and nothing here reads
124
+ // `slot_name` or any consumer/business label: it is a projection adapter.
125
+ // ---------------------------------------------------------------------
126
+ app.get('/capabilities', this.refetchAuth, (req, res) => {
127
+ const jobs = this.handlers.jobs;
128
+ if (!jobs) {
129
+ res.status(503).json(this.jobUnavailableResponse());
130
+ return;
131
+ }
132
+ try {
133
+ res.json(jobs.capabilities());
134
+ }
135
+ catch (error) {
136
+ this.jobFailure(req, res, error, 'capabilities failed');
137
+ }
138
+ });
139
+ app.post('/jobs', this.refetchAuth, (req, res) => {
140
+ const jobs = this.handlers.jobs;
141
+ if (!jobs) {
142
+ res.status(503).json(this.jobUnavailableResponse());
143
+ return;
144
+ }
145
+ try {
146
+ const { job, replayed } = jobs.submitJob(req.body);
147
+ this.triggerOutcome('schedule.job_admitted', replayed ? 200 : 202, req, res, {
148
+ job_id: job.job_id,
149
+ job_type: job.job_type,
150
+ status: job.status,
151
+ disposition: replayed ? 'replayed' : 'accepted',
152
+ });
153
+ // 202 on first admission, 200 when the idempotency key resolved to an
154
+ // already-existing Job. Both carry the identical `job` body.
155
+ res.status(replayed ? 200 : 202).json({ job });
156
+ }
157
+ catch (error) {
158
+ this.jobFailure(req, res, error, 'job admission failed');
159
+ }
160
+ });
161
+ app.get('/jobs', this.refetchAuth, (req, res) => {
162
+ const jobs = this.handlers.jobs;
163
+ if (!jobs) {
164
+ res.status(503).json(this.jobUnavailableResponse());
165
+ return;
166
+ }
167
+ try {
168
+ const idempotencyKey = req.query.idempotency_key;
169
+ if (typeof idempotencyKey !== 'string' || idempotencyKey.trim() === '') {
170
+ throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 400, {
171
+ message: 'idempotency_key is required',
172
+ });
173
+ }
174
+ res.json({ jobs: jobs.jobsByIdempotencyKey(idempotencyKey), server_time: Date.now() });
175
+ }
176
+ catch (error) {
177
+ this.jobFailure(req, res, error, 'job lookup failed');
178
+ }
179
+ });
180
+ app.get('/jobs/:jobId', this.refetchAuth, (req, res) => {
181
+ const jobs = this.handlers.jobs;
182
+ if (!jobs) {
183
+ res.status(503).json(this.jobUnavailableResponse());
184
+ return;
185
+ }
186
+ try {
187
+ const job = jobs.jobStatus(req.params.jobId);
188
+ if (!job) {
189
+ throw new ProtocolErrors_1.ProtocolRequestError('invalid_params', 404, { message: 'unknown job' });
190
+ }
191
+ res.json(job);
192
+ }
193
+ catch (error) {
194
+ this.jobFailure(req, res, error, 'job lookup failed');
195
+ }
196
+ });
197
+ app.post('/jobs/:jobId/cancel', this.refetchAuth, (req, res) => {
198
+ const jobs = this.handlers.jobs;
199
+ if (!jobs) {
200
+ res.status(503).json(this.jobUnavailableResponse());
201
+ return;
202
+ }
203
+ try {
204
+ const job = jobs.cancelJob(req.params.jobId);
205
+ this.triggerOutcome('schedule.job_cancelled', 200, req, res, {
206
+ job_id: job.job_id,
207
+ status: job.status,
208
+ });
209
+ res.json(job);
210
+ }
211
+ catch (error) {
212
+ this.jobFailure(req, res, error, 'job cancel failed');
213
+ }
214
+ });
215
+ // The durable event stream (§events). Read-only projection of the ledger the
216
+ // cancel/status routes already read, so a consumer can follow a job to its
217
+ // terminal event instead of polling status.
218
+ app.get('/jobs/:jobId/events', this.refetchAuth, (req, res) => {
219
+ const jobs = this.handlers.jobs;
220
+ if (!jobs) {
221
+ res.status(503).json(this.jobUnavailableResponse());
222
+ return;
223
+ }
224
+ try {
225
+ const query = (0, JobFacade_1.parseEventQuery)(req.query);
226
+ res.json(jobs.jobEvents(req.params.jobId, query));
227
+ }
228
+ catch (error) {
229
+ this.jobFailure(req, res, error, 'job events failed');
230
+ }
231
+ });
232
+ // Ack is a cursor write, never a job mutation: the body carries only
233
+ // `ack_through`, and an unknown or older cursor is accepted as a no-op.
234
+ app.post('/jobs/:jobId/events/ack', this.refetchAuth, (req, res) => {
235
+ const jobs = this.handlers.jobs;
236
+ if (!jobs) {
237
+ res.status(503).json(this.jobUnavailableResponse());
238
+ return;
239
+ }
240
+ try {
241
+ res.json(jobs.ackJobEvents(req.params.jobId, req.body));
242
+ }
243
+ catch (error) {
244
+ this.jobFailure(req, res, error, 'job ack failed');
245
+ }
246
+ });
111
247
  // Read-only: list enabled schedules + their current occurrence status.
112
248
  app.get('/internal/schedules', this.auth, (_req, res) => {
113
249
  const schedules = this.handlers.listSchedules().map((id) => this.handlers.status(id));
@@ -291,9 +427,11 @@ class ScheduleTriggerServer {
291
427
  res.status(500).json({ status: 'error', error: 'outbox drain failed; rows remain durable and retry' });
292
428
  }
293
429
  });
294
- this.server = app.listen(port, host, () => {
430
+ const server = app.listen(port, host, () => {
295
431
  logger_1.logger.info('Schedule trigger server listening', { host, port, auth: this.token ? 'bearer' : 'DISABLED (no token)' });
296
432
  });
433
+ this.server = server;
434
+ return server;
297
435
  }
298
436
  /**
299
437
  * Per-request correlation, installed before every route. It records only the
@@ -345,6 +483,33 @@ class ScheduleTriggerServer {
345
483
  elapsed_ms: Date.now() - startedAt,
346
484
  };
347
485
  }
486
+ /**
487
+ * Every generic-surface failure is written as a protocol `Error`: a closed-enum
488
+ * `code`, a `retryable` boolean from the published defaults, and a `detail`
489
+ * object. Nothing here judges a failure by its message text.
490
+ */
491
+ jobFailure(req, res, error, stage) {
492
+ const { status, body } = (0, ProtocolErrors_1.protocolErrorResponse)(error);
493
+ if (status >= 500) {
494
+ logger_1.logger.error('Job API request failed', {
495
+ ...this.attemptMeta(req, res),
496
+ stage,
497
+ error: error instanceof Error ? error.message : String(error),
498
+ });
499
+ }
500
+ this.triggerOutcome('schedule.job_rejected', status, req, res, { code: body.code, stage });
501
+ // §3: protocol failures are reported as `{ error: <$defs/Error> }`.
502
+ res.status(status).json({ error: body });
503
+ }
504
+ /** Fail closed when the generic surface is not mounted in this runtime. */
505
+ jobUnavailableResponse() {
506
+ return {
507
+ error: (0, ProtocolErrors_1.protocolErrorBody)('internal_error', {
508
+ message: 'job API is unavailable',
509
+ detail: { reason: 'job_api_disabled' },
510
+ }),
511
+ };
512
+ }
348
513
  auth = (req, res, next) => {
349
514
  this.authenticate(this.token, req, res, next);
350
515
  };
@@ -6,12 +6,14 @@
6
6
  * - System failures (pixiv/network/download/processing/delivery) must be kept
7
7
  * disjoint from business no-content states so monitoring and Mini App never
8
8
  * show "internal_error" for an empty but healthy run.
9
+ * - A CONSUMER CANCEL is an intentional stop, not a system failure: it must
10
+ * never be alertable, or every cancel would page an operator.
9
11
  *
10
12
  * This is a DERIVED verdict over the existing terminal cell ledger; it does not
11
13
  * change the persisted slot phase machine (pending/running/success/partial/
12
14
  * failed remain untouched for ledger compatibility).
13
15
  */
14
- export type SlotBusinessStatus = 'success' | 'partial_success' | 'no_candidate' | 'duplicate_only' | 'failed';
16
+ export type SlotBusinessStatus = 'success' | 'partial_success' | 'no_candidate' | 'duplicate_only' | 'cancelled' | 'failed';
15
17
  export interface SlotBusinessCounts {
16
18
  total: number;
17
19
  submitted: number;
@@ -21,6 +23,8 @@ export interface SlotBusinessCounts {
21
23
  duplicate_exhausted?: number;
22
24
  executor_failed: number;
23
25
  delivery_failed: number;
26
+ /** Cells terminalised by a consumer/operator cancel (`cancelled_by_consumer`). */
27
+ cancelled?: number;
24
28
  }
25
29
  /** Pure projection: durable cell counts -> one business verdict. No I/O. */
26
30
  export declare function classifySlotBusinessStatus(counts: SlotBusinessCounts): SlotBusinessStatus;
@@ -13,6 +13,10 @@ function classifySlotBusinessStatus(counts) {
13
13
  return 'partial_success';
14
14
  if (nonSubmitted === 0)
15
15
  return 'success'; // defensive: 0 targets / all submitted
16
+ // A cancel is deliberate. It is only reported as such while nothing else in
17
+ // the occurrence actually failed — a real failure must stay visible.
18
+ if ((counts.cancelled ?? 0) > 0 && !systemFailed)
19
+ return 'cancelled';
16
20
  if (!systemFailed) {
17
21
  // Every non-submitted cell was a BUSINESS no-content verdict; all of them
18
22
  // came from already-delivered candidates => duplicate_only.
@@ -36,6 +40,8 @@ function userMessageForSlotBusinessStatus(status) {
36
40
  return '本轮没有发现新的可发布作品。任务已正常完成。';
37
41
  case 'duplicate_only':
38
42
  return '本轮没有发现新的可发布作品。任务已正常完成。';
43
+ case 'cancelled':
44
+ return '本轮任务已被取消,不会继续发布。';
39
45
  case 'failed':
40
46
  return '本轮任务遇到系统异常,请稍后重试或检查日志。';
41
47
  }
@@ -57,6 +57,13 @@ export interface SlotContext {
57
57
  recoveryRequestId?: string;
58
58
  /** Recovery policy preset ('normal' | 'relaxed'); null for non-recovery slots. */
59
59
  recoveryMode?: 'normal' | 'relaxed';
60
+ /**
61
+ * Occurrence-scoped retrieval view of a generic `candidate_search` job (§6),
62
+ * serialized as JSON. Persisted with the slot so a worker that recovers the
63
+ * occurrence re-applies the requester's retrieval instead of quietly falling
64
+ * back to the plan defaults. Absent for scheduled occurrences.
65
+ */
66
+ paramsJson?: string;
60
67
  }
61
68
  export interface SlotCellSummary {
62
69
  targetId: string;
@@ -94,10 +101,13 @@ export interface ScheduleOutcomeTarget {
94
101
  * healthy run look broken, and a broken one look routine.
95
102
  *
96
103
  * Each cell lands in exactly ONE category, in this precedence:
97
- * submitted -> no_match -> duplicate -> delivery_failed -> executor_failed.
104
+ * submitted -> no_match -> duplicate -> delivery_failed -> cancelled -> executor_failed.
98
105
  * `delivery_failed` is checked before `executor_failed` because a terminally
99
106
  * lost delivery ALSO leaves the cell in the `failed` state — counting it twice
100
- * would invent a second failure that does not exist.
107
+ * would invent a second failure that does not exist. `cancelled` comes before
108
+ * `executor_failed` for the same reason: a consumer cancel also ends as a
109
+ * `failed` cell, and counting it as an executor failure would page an operator
110
+ * for a deliberate stop.
101
111
  */
102
112
  export interface ScheduleOutcomeCells {
103
113
  total: number;
@@ -108,6 +118,8 @@ export interface ScheduleOutcomeCells {
108
118
  all_duplicates: boolean;
109
119
  executor_failed: number;
110
120
  delivery_failed: number;
121
+ /** Cells stopped by a consumer/operator cancel — deliberate, never alertable. */
122
+ cancelled: number;
111
123
  targets: ScheduleOutcomeTarget[];
112
124
  }
113
125
  /**
@@ -119,13 +131,13 @@ export interface ScheduleOutcomeCells {
119
131
  export interface ScheduleOutcomeRecord {
120
132
  event: 'schedule.outcome';
121
133
  /** Outcome taxonomy version; bump when business_status/reason gains codes. */
122
- outcome_version: 1;
134
+ outcome_version: 2;
123
135
  schedule_id: string;
124
136
  slot_id: string;
125
137
  occurrence_at: string | undefined;
126
138
  occurrence_date: string;
127
139
  status: ScheduleOutcomeStatus;
128
- /** Derived business verdict: success / partial_success / no_candidate / duplicate_only / failed. */
140
+ /** Derived business verdict: success / partial_success / no_candidate / duplicate_only / cancelled / failed. */
129
141
  business_status: SlotBusinessStatus;
130
142
  /** Monitoring gate: true only for system failures (business_status === 'failed'). */
131
143
  alertable: boolean;
@@ -305,7 +317,35 @@ export declare class SlotCoordinator {
305
317
  * terminal outcome apply).
306
318
  */
307
319
  advanceFallback(slotId: string, targetId: string, reason: string, maxStages: number): number;
308
- finish(slot: SlotContext, schedule: ScheduleConfig, targets: TargetConfig[]): SlotRunSummary;
320
+ /**
321
+ * True when the cell's durable delivery intent can no longer be completed by
322
+ * any worker: at least one intent row exists (so this IS a delivery that was
323
+ * handed off, not an unstarted cell), none is confirmed, and none still has an
324
+ * actionable outbox row (dead / cancelled / lost with the process).
325
+ *
326
+ * Answered from the LEDGER rather than from the injected delivery port on
327
+ * purpose: `finish` is also called by the schedule-summary reconciler, which
328
+ * has no port, and "will anything still deliver this?" is a durable fact.
329
+ * `unknown` (no intent at all) must never converge a cell — an unstarted or
330
+ * port-less rollup keeps today's behaviour.
331
+ */
332
+ private abandonedDelivery;
333
+ /**
334
+ * A LIVE lease owned by someone other than the finishing run. Blocking on it
335
+ * is what stops a rollup (or the reconciler, which passes no owner) from
336
+ * converging cells that a concurrently running worker still owns.
337
+ */
338
+ private foreignLiveLease;
339
+ /**
340
+ * @param options.leaseOwner the lease owner of the run that is finishing.
341
+ * A LIVE lease held by a DIFFERENT owner means another worker owns this slot
342
+ * right now, so an abandoned-looking cell must be left to that worker
343
+ * instead of being converged from here (the reconciler path passes nothing,
344
+ * so any live lease blocks convergence for it).
345
+ */
346
+ finish(slot: SlotContext, schedule: ScheduleConfig, targets: TargetConfig[], options?: {
347
+ leaseOwner?: string;
348
+ }): SlotRunSummary;
309
349
  /**
310
350
  * Project the terminal slot row into the one structured outcome record. Every
311
351
  * field comes from durable state — the slot row, its cells, and the delivery
@@ -317,6 +357,10 @@ export declare class SlotCoordinator {
317
357
  * durable delivery ledger through the SAME port the FSM already uses, never
318
358
  * from an error string, and it is decided BEFORE `executor_failed` because a
319
359
  * terminally lost delivery also leaves the cell `failed`: one cause, one count.
360
+ *
361
+ * A consumer cancel is decided before both: it also ends as a `failed` cell,
362
+ * but it is a deliberate stop rather than a system failure, so it must not be
363
+ * counted as an executor failure (which would make it alertable).
320
364
  */
321
365
  private classifyCell;
322
366
  /**
@@ -5,7 +5,9 @@ exports.timezoneForSchedule = timezoneForSchedule;
5
5
  const logger_1 = require("../logger");
6
6
  const OccurrenceResolver_1 = require("./OccurrenceResolver");
7
7
  const TargetOutcome_1 = require("./TargetOutcome");
8
+ const ledger_time_1 = require("./ledger-time");
8
9
  const SlotBusinessStatus_1 = require("./SlotBusinessStatus");
10
+ const ProtocolErrors_1 = require("./ProtocolErrors");
9
11
  const WorkIdentity_1 = require("./WorkIdentity");
10
12
  const targetRoutes_1 = require("../delivery/targetRoutes");
11
13
  /**
@@ -31,20 +33,6 @@ function deliveryTargetsOf(target) {
31
33
  return [];
32
34
  return (0, targetRoutes_1.targetDeliveryNames)(target);
33
35
  }
34
- /**
35
- * SQLite `CURRENT_TIMESTAMP` is UTC "YYYY-MM-DD HH:MM:SS". It carries no zone
36
- * marker, and `Date.parse` reads that shape as LOCAL time — so the zone is
37
- * added explicitly instead of being trusted to the engine.
38
- */
39
- function sqliteUtcMs(value) {
40
- if (!value)
41
- return undefined;
42
- const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value)
43
- ? `${value.replace(' ', 'T')}Z`
44
- : value;
45
- const ms = Date.parse(normalized);
46
- return Number.isNaN(ms) ? undefined : ms;
47
- }
48
36
  /**
49
37
  * Epoch ms -> ISO 8601 UTC, or undefined when it is not a usable instant.
50
38
  * `toISOString` throws on an out-of-range Date, and a throw here would abort the
@@ -135,6 +123,7 @@ class SlotCoordinator {
135
123
  slotName: slot.slotName,
136
124
  manualRequestId: slot.manualRequestId ?? null,
137
125
  correlationId: slot.correlationId ?? null,
126
+ paramsJson: slot.paramsJson ?? null,
138
127
  recoveryRequestId: slot.recoveryRequestId ?? null,
139
128
  recoveryMode: slot.recoveryMode ?? null,
140
129
  });
@@ -434,17 +423,82 @@ class SlotCoordinator {
434
423
  });
435
424
  return next;
436
425
  }
437
- finish(slot, schedule, targets) {
426
+ /**
427
+ * True when the cell's durable delivery intent can no longer be completed by
428
+ * any worker: at least one intent row exists (so this IS a delivery that was
429
+ * handed off, not an unstarted cell), none is confirmed, and none still has an
430
+ * actionable outbox row (dead / cancelled / lost with the process).
431
+ *
432
+ * Answered from the LEDGER rather than from the injected delivery port on
433
+ * purpose: `finish` is also called by the schedule-summary reconciler, which
434
+ * has no port, and "will anything still deliver this?" is a durable fact.
435
+ * `unknown` (no intent at all) must never converge a cell — an unstarted or
436
+ * port-less rollup keeps today's behaviour.
437
+ */
438
+ abandonedDelivery(slotId, targetId) {
439
+ const intents = this.database.deliveries.listForSlotCell(slotId, targetId);
440
+ if (intents.length === 0)
441
+ return false;
442
+ if (intents.some((row) => row.status === 'delivered' || row.status === 'duplicate'))
443
+ return false;
444
+ return !intents.some((row) => row.status === 'pending' && this.database.outbox.hasActionableDelivery(row.id));
445
+ }
446
+ /**
447
+ * A LIVE lease owned by someone other than the finishing run. Blocking on it
448
+ * is what stops a rollup (or the reconciler, which passes no owner) from
449
+ * converging cells that a concurrently running worker still owns.
450
+ */
451
+ foreignLiveLease(slotId, ownOwner) {
452
+ const lease = this.database.slots.getSlotLease(slotId);
453
+ if (lease.owner === null || lease.until === null || lease.until <= Date.now())
454
+ return false;
455
+ return ownOwner === undefined || lease.owner !== ownOwner;
456
+ }
457
+ /**
458
+ * @param options.leaseOwner the lease owner of the run that is finishing.
459
+ * A LIVE lease held by a DIFFERENT owner means another worker owns this slot
460
+ * right now, so an abandoned-looking cell must be left to that worker
461
+ * instead of being converged from here (the reconciler path passes nothing,
462
+ * so any live lease blocks convergence for it).
463
+ */
464
+ finish(slot, schedule, targets, options = {}) {
438
465
  const membership = this.database.slots.getSlotTargetIds(slot.slotId);
439
466
  const ids = membership.length > 0 ? membership : targets.map((t) => t.id).filter(Boolean);
467
+ const foreignLiveLease = this.foreignLiveLease(slot.slotId, options.leaseOwner);
440
468
  for (const targetId of ids) {
441
469
  const cell = this.database.slots.getCell(slot.slotId, targetId);
442
470
  if (!cell)
443
471
  continue;
444
472
  // delivery_pending / artifact_ready are recoverable: the OutboxWorker (or
445
- // the next trigger) resumes the SAME work, so the slot stays running.
446
- if (cell.status === 'delivery_pending' || cell.status === 'artifact_ready')
473
+ // the next trigger) resumes the SAME work, so the slot stays running — but
474
+ // ONLY while a durable delivery intent still has an actionable outbox row.
475
+ // A dead-lettered/cancelled intent (or one lost with the process) has no
476
+ // worker left to converge it, so the cell would stay non-terminal forever
477
+ // and `deriveSlotStatus` would keep reporting `running`.
478
+ if (cell.status === 'delivery_pending' || cell.status === 'artifact_ready') {
479
+ if (foreignLiveLease)
480
+ continue;
481
+ if (!this.abandonedDelivery(slot.slotId, targetId))
482
+ continue;
483
+ const error = cell.lastError ?? TargetOutcome_1.TERMINAL_REASON_MESSAGES.delivery_abandoned;
484
+ try {
485
+ this.database.slots.transitionCell(slot.slotId, targetId, 'failed', error);
486
+ this.database.slots.setCellTerminalReason(slot.slotId, targetId, 'delivery_abandoned', TargetOutcome_1.TERMINAL_REASON_MESSAGES.delivery_abandoned);
487
+ }
488
+ catch (reasonError) {
489
+ logger_1.logger.debug('Failed to converge an abandoned delivery cell', {
490
+ slot: slot.slotId, target: targetId,
491
+ error: reasonError instanceof Error ? reasonError.message : String(reasonError),
492
+ });
493
+ continue;
494
+ }
495
+ logger_1.logger.warn('Converged an abandoned delivery cell: no actionable outbox row remained', {
496
+ slot: slot.slotId,
497
+ target: targetId,
498
+ manualRequestId: slot.manualRequestId ?? null,
499
+ });
447
500
  continue;
501
+ }
448
502
  if (cell.status === 'pending' || cell.status === 'selected') {
449
503
  // Ran but never reached a terminal state (target threw before delivery).
450
504
  // Persist a normalized reason so the operator sees a first-level cause,
@@ -503,8 +557,9 @@ class SlotCoordinator {
503
557
  let duplicate = 0;
504
558
  let executor_failed = 0;
505
559
  let delivery_failed = 0;
560
+ let cancelled = 0;
506
561
  const targetsDetail = cellRows.map((cell) => {
507
- const classified = this.classifyCell(cell.status, slot.slotId, cell.targetId, deliveryTargetsByTargetId);
562
+ const classified = this.classifyCell(cell, slot.slotId, deliveryTargetsByTargetId);
508
563
  if (classified === 'submitted')
509
564
  submitted += 1;
510
565
  else if (classified === 'no_match')
@@ -513,6 +568,8 @@ class SlotCoordinator {
513
568
  duplicate += 1;
514
569
  else if (classified === 'delivery_failed')
515
570
  delivery_failed += 1;
571
+ else if (classified === 'cancelled')
572
+ cancelled += 1;
516
573
  else if (classified === 'executor_failed')
517
574
  executor_failed += 1;
518
575
  return {
@@ -529,8 +586,8 @@ class SlotCoordinator {
529
586
  // `all_duplicates` there would be a lie — so the count must be non-zero.
530
587
  const nonSubmitted = cellRows.length - submitted;
531
588
  const all_duplicates = nonSubmitted > 0 && duplicate === nonSubmitted;
532
- const startedMs = sqliteUtcMs(slotRec?.startedAt);
533
- const completedMs = sqliteUtcMs(slotRec?.completedAt);
589
+ const startedMs = (0, ledger_time_1.sqliteUtcMs)(slotRec?.startedAt);
590
+ const completedMs = (0, ledger_time_1.sqliteUtcMs)(slotRec?.completedAt);
534
591
  const duration_ms = startedMs !== undefined && completedMs !== undefined
535
592
  ? Math.max(0, completedMs - startedMs)
536
593
  : undefined;
@@ -543,10 +600,11 @@ class SlotCoordinator {
543
600
  duplicate_exhausted: targetsDetail.filter((t) => t.terminal_reason_code === 'duplicate_exhausted').length,
544
601
  executor_failed,
545
602
  delivery_failed,
603
+ cancelled,
546
604
  });
547
605
  return {
548
606
  event: 'schedule.outcome',
549
- outcome_version: 1,
607
+ outcome_version: 2,
550
608
  schedule_id: slotRec?.scheduleId ?? slot.scheduleId,
551
609
  slot_id: slot.slotId,
552
610
  occurrence_at: isoUtcOrUndefined(occurrenceAt),
@@ -563,6 +621,7 @@ class SlotCoordinator {
563
621
  all_duplicates,
564
622
  executor_failed,
565
623
  delivery_failed,
624
+ cancelled,
566
625
  targets: targetsDetail,
567
626
  },
568
627
  };
@@ -572,14 +631,22 @@ class SlotCoordinator {
572
631
  * durable delivery ledger through the SAME port the FSM already uses, never
573
632
  * from an error string, and it is decided BEFORE `executor_failed` because a
574
633
  * terminally lost delivery also leaves the cell `failed`: one cause, one count.
634
+ *
635
+ * A consumer cancel is decided before both: it also ends as a `failed` cell,
636
+ * but it is a deliberate stop rather than a system failure, so it must not be
637
+ * counted as an executor failure (which would make it alertable).
575
638
  */
576
- classifyCell(cellStatus, slotId, targetId, deliveryTargetsByTargetId) {
639
+ classifyCell(cell, slotId, deliveryTargetsByTargetId) {
640
+ const cellStatus = cell.status;
641
+ const targetId = cell.targetId;
577
642
  if (cellStatus === 'submitted')
578
643
  return 'submitted';
579
644
  if (cellStatus === 'no_candidate')
580
645
  return 'no_match';
581
646
  if (cellStatus === 'duplicate')
582
647
  return 'duplicate';
648
+ if (cellStatus === 'failed' && cell.terminalReasonCode === ProtocolErrors_1.CANCELLED_BY_CONSUMER)
649
+ return 'cancelled';
583
650
  const deliveryTargets = deliveryTargetsByTargetId.get(targetId);
584
651
  if (deliveryTargets && deliveryTargets.length > 0 && this.delivery) {
585
652
  try {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Liveness sweep for the durable Slot ledger (§liveness).
3
+ *
4
+ * Accepted work must not be able to sit in a non-terminal state forever. Two
5
+ * situations can strand a Slot that no worker will ever touch again:
6
+ *
7
+ * - `queued_too_long`: the Slot was admitted (202) but nothing ever claimed it
8
+ * — the plan is disabled, its Scheduler is stopped, or the resource wait
9
+ * queue never drained. `recoverableSlots()` retries such rows on every tick,
10
+ * which keeps reporting them as "unfinished" forever when admission keeps
11
+ * failing.
12
+ * - `stalled_no_heartbeat`: a worker claimed the Slot and then stopped
13
+ * heartbeating (crash, OOM, redeploy window). Its lease expires, but nothing
14
+ * terminalises the row, so `countActiveSlots()` keeps the worker alive and a
15
+ * caller polling the job state sees a frozen `running`.
16
+ *
17
+ * Both rules are LIVENESS-based and deliberately generous: a genuinely
18
+ * progressing job renews its lease every 30s and writes a heartbeat, so it can
19
+ * never be selected — however long a real Pixiv search takes (production runs of
20
+ * 10-40 minutes, occasionally queued hours behind a busy account).
21
+ *
22
+ * The sweep only ever writes terminal states for cells that never made progress
23
+ * (`pending`/`selected`). A cell already handed to the delivery ledger keeps its
24
+ * own lifecycle; `SlotCoordinator.finish` is what converges an abandoned one.
25
+ */
26
+ import type { Database } from '../storage/Database';
27
+ /** Nothing is ever stalled before this: a floor against a misconfigured budget. */
28
+ export declare const STALL_SWEEP_MIN_TIMEOUT_MS: number;
29
+ /** Admitted but never claimed for this long -> `queued_too_long`. */
30
+ export declare const DEFAULT_QUEUED_TIMEOUT_MS: number;
31
+ /** No heartbeat/lease for this long while `running` -> `stalled_no_heartbeat`. */
32
+ export declare const DEFAULT_STALL_TIMEOUT_MS: number;
33
+ /** Bounded batch: one tick must stay cheap on a large ledger. */
34
+ export declare const STALL_SWEEP_BATCH_LIMIT = 100;
35
+ export interface StallTimeouts {
36
+ queuedTimeoutMs: number;
37
+ stallTimeoutMs: number;
38
+ }
39
+ /** The two budgets as configured, each clamped to the floor / its default. */
40
+ export declare function resolveStallTimeouts(rt?: {
41
+ queuedTimeoutMs?: unknown;
42
+ stallTimeoutMs?: unknown;
43
+ }): StallTimeouts;
44
+ export interface StallSweepOptions extends Partial<StallTimeouts> {
45
+ /** Injectable clock (tests). */
46
+ now?: number;
47
+ /** Max rows examined per rule per tick. */
48
+ limit?: number;
49
+ /**
50
+ * Slots recovery re-dispatched on this same tick. They are owned by an
51
+ * asynchronous run that has not claimed its lease yet, so they are excluded
52
+ * here: crash-resume must not be turned into a failure by the sweep that runs
53
+ * right after it.
54
+ */
55
+ skipSlotIds?: ReadonlySet<string>;
56
+ }
57
+ export interface StallSweepResult {
58
+ /** Rows examined (both rules together). */
59
+ scanned: number;
60
+ queuedTooLong: number;
61
+ stalledNoHeartbeat: number;
62
+ }
63
+ /** One bounded, liveness-aware sweep. Never throws for a single bad row. */
64
+ export declare function sweepStalledSlots(database: Database, options?: StallSweepOptions): StallSweepResult;
65
+ //# sourceMappingURL=StallSweep.d.ts.map