omp-conductor 0.19.5 → 0.19.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/fleet.ts CHANGED
@@ -223,6 +223,46 @@ export function armState(projectName?: string): { path: string; armed: boolean }
223
223
  return { path, armed: resolveArmState(path, named).armed };
224
224
  }
225
225
 
226
+ export type WatchMonitoring =
227
+ | { monitored: true }
228
+ | { monitored: false; reason: string };
229
+
230
+ /** Whether a durable watch can be observed and wake an orchestrator tick (#1024). */
231
+ export function watchMonitoringState(
232
+ layers: Pick<FleetLayers, "daemon" | "ticks">,
233
+ ): WatchMonitoring {
234
+ if (!layers.daemon.running) return { monitored: false, reason: "daemon not running" };
235
+ if (layers.ticks === "armed" || layers.ticks === "ungated") return { monitored: true };
236
+ if (layers.ticks === "disarmed") {
237
+ return {
238
+ monitored: false,
239
+ reason: "ticks disarmed — conditions may still be checked, but no tick can be woken",
240
+ };
241
+ }
242
+ return { monitored: false, reason: `ticks ${layers.ticks}` };
243
+ }
244
+
245
+ /** Cheap watch liveness read: no Herdr, Telegram, healthz, or systemd probes. */
246
+ export function watchMonitoringForProject(projectName: string): WatchMonitoring {
247
+ const daemon = livingDaemon();
248
+ if (daemon === undefined) return { monitored: false, reason: "daemon not running" };
249
+ if (daemon.project !== undefined && daemon.project !== projectName) {
250
+ return { monitored: false, reason: `daemon serves ${daemon.project}, not ${projectName}` };
251
+ }
252
+ const tick = resolveTickConfig(projectName);
253
+ const ticks: TicksLayer =
254
+ tick.kind === "absent"
255
+ ? "no-heartbeat-config"
256
+ : tick.kind === "invalid"
257
+ ? "invalid-heartbeat-config"
258
+ : tick.config.armedFile === undefined
259
+ ? "ungated"
260
+ : armState(projectName).armed
261
+ ? "armed"
262
+ : "disarmed";
263
+ return watchMonitoringState({ daemon: { running: true }, ticks });
264
+ }
265
+
226
266
  /**
227
267
  * Clears the arm gate for this project — and `wasArmed` is the gate the
228
268
  * heartbeat reads, not merely one file's presence.
@@ -2174,7 +2214,7 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
2174
2214
  telegram,
2175
2215
  codeGraph,
2176
2216
  brief: briefStatusLine(project),
2177
- decisions: decisionStatusLine(project.name),
2217
+ decisions: decisionStatusLine(project.name, layers),
2178
2218
  failureClasses: failureClassBlock(project.name),
2179
2219
  workerPhases,
2180
2220
  intake: intakeStatusLine(project.name),
@@ -2307,7 +2347,10 @@ function failureClassBlock(projectName: string): string | undefined {
2307
2347
  * watches behind GitHub's checks is not a fleet waiting on its operator, and
2308
2348
  * lumping them in made `decisions 3 open` read as three unanswered questions.
2309
2349
  */
2310
- export function decisionStatusLine(projectName: string): string | undefined {
2350
+ export function decisionStatusLine(
2351
+ projectName: string,
2352
+ layers: Pick<FleetLayers, "daemon" | "ticks">,
2353
+ ): string | undefined {
2311
2354
  const path = dbPath();
2312
2355
  if (!existsSync(path)) return undefined;
2313
2356
  let store: Store | undefined;
@@ -2325,7 +2368,12 @@ export function decisionStatusLine(projectName: string): string | undefined {
2325
2368
  const hours = Math.max(0, Math.round((Date.now() - oldest.askedAt) / 3_600_000));
2326
2369
  line = `decisions ${questions.length} open (oldest ${hours}h)`;
2327
2370
  }
2328
- if (watches.length > 0) line += ` · watches ${watches.length}`;
2371
+ if (watches.length > 0) {
2372
+ const monitoring = watchMonitoringState(layers);
2373
+ line += monitoring.monitored
2374
+ ? ` · watches ${watches.length} monitored`
2375
+ : ` · watches ${watches.length} durable, unmonitored (${monitoring.reason})`;
2376
+ }
2329
2377
  return line;
2330
2378
  } catch {
2331
2379
  return undefined;
@@ -49,7 +49,13 @@ import { spawnSync } from "node:child_process";
49
49
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
50
50
  import { dirname, isAbsolute, join, resolve } from "node:path";
51
51
  import { availabilityPrompt, interruptDisposition } from "./availability.ts";
52
- import { findProject, loadConfig, resolveReleaseGrants, stateDir } from "./config.ts";
52
+ import {
53
+ findProject,
54
+ loadConfig,
55
+ resolveReleaseGrants,
56
+ resolveSharedInstallAuthority,
57
+ stateDir,
58
+ } from "./config.ts";
53
59
  import {
54
60
  bridgeTokenBound,
55
61
  hasBotToken,
@@ -4393,9 +4399,14 @@ export default function orchestratorTickExtension(
4393
4399
  let grants: ResolvedGrants = DENIED_RELEASE_GRANTS;
4394
4400
  let external = true;
4395
4401
  try {
4396
- const project = findProject(loadConfig(), configuredProject);
4402
+ const config = loadConfig();
4403
+ const project = findProject(config, configuredProject);
4404
+ const installAuthority = resolveSharedInstallAuthority(config.projects);
4397
4405
  projectName = project.name;
4398
- grants = resolveReleaseGrants(project);
4406
+ grants = {
4407
+ ...resolveReleaseGrants(project),
4408
+ install: installAuthority.holder ?? "human",
4409
+ };
4399
4410
  external = project.escalation.orchestrator === "external";
4400
4411
  } catch (err) {
4401
4412
  // A missing/unreadable config cannot open a release gate. Log only when
package/src/setup-host.ts CHANGED
@@ -780,11 +780,11 @@ export function renderHerdrUnit(runtime: ServiceRuntime): string {
780
780
  * recovery.
781
781
  */
782
782
  export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string | undefined): string {
783
- // `RECOVER_PROJECT` is how the playbook addresses the tier-2 escalation it
784
- // enqueues. It is a host-global unit installed once for the whole box, so a
785
- // static per-project value is only unambiguous on a single-project host a
786
- // multi-project host (or a no-project, host-global install) leaves it unset
787
- // and reports without a `--project` rather than guessing one (#510/#530).
783
+ // `RECOVER_PROJECT` scopes the recovery action and optional re-arm. It is
784
+ // host-global and installed once, so a static action scope is legitimate
785
+ // only on a single-project host. With several projects it stays unset; the
786
+ // playbook independently derives one deterministic outbox owner from the
787
+ // live config and names every affected project in the report (#1027).
788
788
  return [
789
789
  "[Unit]",
790
790
  "Description=omp-conductor fleet recovery (OnFailure handler)",
@@ -1566,11 +1566,11 @@ export function planHostRuntime(
1566
1566
  const installedPath = join(unitDir, STAGED_SERVICE_NAME);
1567
1567
  const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
1568
1568
  const recoverUnitPath = join(stateDir(), RECOVER_SERVICE_NAME);
1569
- // The recovery unit is host-global: one shared unit, installed once. A
1570
- // static RECOVER_PROJECT is only legitimate when there is exactly one
1571
- // project to be unambiguous about — a multi-project host (or a no-project
1572
- // install) leaves it unset so the escalation reports without attributing a
1573
- // sibling's crash to one project (#510/#530).
1569
+ // The recovery unit is host-global: one shared unit, installed once.
1570
+ // `RECOVER_PROJECT` scopes recovery action/re-arm only and is therefore
1571
+ // omitted on a multi-project host. Report ownership is a separate runtime
1572
+ // decision: the playbook reads the configured project set, chooses its first
1573
+ // project as outbox owner, and lists every project as affected (#1027).
1574
1574
  const recoverProject = project === undefined || multiProject ? undefined : project.name;
1575
1575
  const recoverUnitContent = renderRecoverUnit(runtime, recoverProject);
1576
1576
  const recoverUnit: PlannedWrite<string> = {
@@ -617,7 +617,7 @@ const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]
617
617
  // package floor's "nobody patches the running conductor" is this grant's
618
618
  // deny default.
619
619
  install:
620
- "install — replace this host's installed conductor: the Bun-global CLI, omp plugin " +
620
+ "install — replace this host's installed conductor: the discovered CLI package, omp plugin " +
621
621
  "and Herdr plugin pinned to one published release, executed detached from the fleet",
622
622
  };
623
623
 
@@ -1018,6 +1018,16 @@ function formatProjectBody(
1018
1018
  lines.push(...formatSalvagedRuns(s.salvagedRuns));
1019
1019
  lines.push(...formatQuarantinedRuns(s.quarantinedRuns));
1020
1020
  lines.push(...formatOpenReports(s.openReports));
1021
+ const withdrawals = s.handoffWithdrawals ?? [];
1022
+ if (withdrawals.length > 0) {
1023
+ lines.push("handoff withdrawals");
1024
+ for (const withdrawal of withdrawals) {
1025
+ lines.push(
1026
+ ` ${withdrawal.id} ${withdrawal.target} ${new Date(withdrawal.at).toISOString()} ` +
1027
+ `by ${withdrawal.actor} — ${withdrawal.reason}; ${withdrawal.summary}`,
1028
+ );
1029
+ }
1030
+ }
1021
1031
  lines.push(...formatDigestBacklog(s.digestBacklog));
1022
1032
  // The mediated-verb ledger (#972). Absent from this renderer since the block
1023
1033
  // was written (#133) — it was wired into `daemon.ts`'s copy, which was
package/src/store.ts CHANGED
@@ -53,6 +53,8 @@ import type {
53
53
  GroomingRecord,
54
54
  GroomingVerdict,
55
55
  HistoricalInfraCandidate,
56
+ HandoffWithdrawal,
57
+ HandoffWithdrawalResult,
56
58
  HeldNotice,
57
59
  InterruptCategory,
58
60
  HeldNoticeDraft,
@@ -430,6 +432,30 @@ interface ReportRow {
430
432
  sentParts: number;
431
433
  sentPartsHash: string | null;
432
434
  lastError: string | null;
435
+ withdrawnAt: number | null;
436
+ withdrawnBy: string | null;
437
+ withdrawReason: string | null;
438
+ }
439
+
440
+ interface HandoffWithdrawalRow {
441
+ id: string;
442
+ project: string;
443
+ target: string;
444
+ actor: string;
445
+ reason: string;
446
+ at: number;
447
+ summary: string;
448
+ detail: string;
449
+ }
450
+
451
+ interface HeldNoticeLifecycleRow {
452
+ id: string;
453
+ project: string;
454
+ summary: string;
455
+ detail: string;
456
+ digestedAt: number | null;
457
+ digestReportId: string | null;
458
+ withdrawnAt: number | null;
433
459
  }
434
460
 
435
461
  /** The `material_events` table exactly as SQLite hands it back (#274). */
@@ -940,13 +966,30 @@ CREATE TABLE IF NOT EXISTS reports (
940
966
  deliveredAt INTEGER,
941
967
  sentParts INTEGER NOT NULL DEFAULT 0,
942
968
  sentPartsHash TEXT,
943
- lastError TEXT
969
+ lastError TEXT,
970
+ withdrawnAt INTEGER,
971
+ withdrawnBy TEXT,
972
+ withdrawReason TEXT
944
973
  );
945
974
  CREATE UNIQUE INDEX IF NOT EXISTS reports_dedupe
946
- ON reports (project, dedupeKey) WHERE dedupeKey IS NOT NULL AND state <> 'failed';
975
+ ON reports (project, dedupeKey)
976
+ WHERE dedupeKey IS NOT NULL AND state <> 'failed' AND state <> 'withdrawn';
947
977
  CREATE INDEX IF NOT EXISTS reports_project_state
948
978
  ON reports (project, state, nextAttemptAt);
949
979
 
980
+ CREATE TABLE IF NOT EXISTS handoff_withdrawals (
981
+ id TEXT PRIMARY KEY,
982
+ project TEXT NOT NULL,
983
+ target TEXT NOT NULL,
984
+ actor TEXT NOT NULL,
985
+ reason TEXT NOT NULL,
986
+ at INTEGER NOT NULL,
987
+ summary TEXT NOT NULL,
988
+ detail TEXT NOT NULL
989
+ );
990
+ CREATE INDEX IF NOT EXISTS handoff_withdrawals_project_at
991
+ ON handoff_withdrawals (project, at DESC);
992
+
950
993
  -- Ordinary material outcomes awaiting authorship in a deferred digest (#274).
951
994
  -- Association happens when a digest is accepted into the outbox, not when the
952
995
  -- session happens to remember the event or when Telegram later delivers it.
@@ -979,7 +1022,10 @@ CREATE TABLE IF NOT EXISTS held_notices (
979
1022
  releaseOnAvailable INTEGER NOT NULL DEFAULT 0,
980
1023
  urgent INTEGER NOT NULL DEFAULT 0,
981
1024
  digestedAt INTEGER,
982
- digestReportId TEXT REFERENCES reports(id)
1025
+ digestReportId TEXT REFERENCES reports(id),
1026
+ withdrawnAt INTEGER,
1027
+ withdrawnBy TEXT,
1028
+ withdrawReason TEXT
983
1029
  );
984
1030
  CREATE INDEX IF NOT EXISTS held_notices_project_undigested
985
1031
  ON held_notices (project, digestedAt) WHERE digestedAt IS NULL;
@@ -1562,9 +1608,25 @@ function toReport(row: ReportRow): ReportRecord {
1562
1608
  if (row.sentPartsHash !== null) record.sentPartsHash = row.sentPartsHash;
1563
1609
  if (row.deliveredAt !== null) record.deliveredAt = row.deliveredAt;
1564
1610
  if (row.lastError !== null) record.lastError = row.lastError;
1611
+ if (row.withdrawnAt !== null) record.withdrawnAt = row.withdrawnAt;
1612
+ if (row.withdrawnBy !== null) record.withdrawnBy = row.withdrawnBy;
1613
+ if (row.withdrawReason !== null) record.withdrawReason = row.withdrawReason;
1565
1614
  return record;
1566
1615
  }
1567
1616
 
1617
+ function toHandoffWithdrawal(row: HandoffWithdrawalRow): HandoffWithdrawal {
1618
+ return {
1619
+ id: row.id,
1620
+ project: row.project,
1621
+ summary: row.summary,
1622
+ detail: row.detail,
1623
+ target: row.target as HandoffWithdrawal["target"],
1624
+ actor: row.actor,
1625
+ reason: row.reason,
1626
+ at: row.at,
1627
+ };
1628
+ }
1629
+
1568
1630
  function toMaterialEvent(row: MaterialEventRow): MaterialEvent {
1569
1631
  return {
1570
1632
  id: row.id,
@@ -1905,20 +1967,20 @@ export function openStore(dbPath: string): Store {
1905
1967
  db.exec("PRAGMA journal_mode = WAL;");
1906
1968
  db.exec("PRAGMA foreign_keys = ON;");
1907
1969
  db.exec(SCHEMA);
1908
- // #274: a terminally failed digest did not count as sent, but the old unique
1909
- // index still blocked a replacement under the same daily key. Replace that
1910
- // one-time schema so owed ledger rows can be handed off again.
1970
+ // Failed or explicitly withdrawn handoffs no longer own a digest dedupe key:
1971
+ // both leave their underlying ledger rows owed for a replacement report.
1911
1972
  const reportDedupeIndex = db
1912
1973
  .query<{ sql: string | null }, []>(
1913
1974
  `SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'reports_dedupe'`,
1914
1975
  )
1915
1976
  .get();
1916
- if (!(reportDedupeIndex?.sql ?? "").includes("state <> 'failed'")) {
1977
+ if (!(reportDedupeIndex?.sql ?? "").includes("state <> 'withdrawn'")) {
1917
1978
  db.exec(
1918
1979
  `BEGIN IMMEDIATE;
1919
1980
  DROP INDEX IF EXISTS reports_dedupe;
1920
1981
  CREATE UNIQUE INDEX reports_dedupe
1921
- ON reports (project, dedupeKey) WHERE dedupeKey IS NOT NULL AND state <> 'failed';
1982
+ ON reports (project, dedupeKey)
1983
+ WHERE dedupeKey IS NOT NULL AND state <> 'failed' AND state <> 'withdrawn';
1922
1984
  COMMIT;`,
1923
1985
  );
1924
1986
  }
@@ -2112,6 +2174,15 @@ export function openStore(dbPath: string): Store {
2112
2174
  }
2113
2175
  // Urgent recovery notices bypass category batching only; they still wait for
2114
2176
  // the configured availability window. Historical notices were not urgent.
2177
+ for (const [name, type] of [
2178
+ ["withdrawnAt", "INTEGER"],
2179
+ ["withdrawnBy", "TEXT"],
2180
+ ["withdrawReason", "TEXT"],
2181
+ ] as const) {
2182
+ if (!heldNoticeColumns.some((column) => column.name === name)) {
2183
+ db.exec(`ALTER TABLE held_notices ADD COLUMN ${name} ${type}`);
2184
+ }
2185
+ }
2115
2186
  if (!heldNoticeColumns.some((column) => column.name === "urgent")) {
2116
2187
  db.exec("ALTER TABLE held_notices ADD COLUMN urgent INTEGER NOT NULL DEFAULT 0");
2117
2188
  }
@@ -2129,6 +2200,15 @@ export function openStore(dbPath: string): Store {
2129
2200
  if (!reportColumns.some((column) => column.name === "sentPartsHash")) {
2130
2201
  db.exec("ALTER TABLE reports ADD COLUMN sentPartsHash TEXT");
2131
2202
  }
2203
+ for (const [name, type] of [
2204
+ ["withdrawnAt", "INTEGER"],
2205
+ ["withdrawnBy", "TEXT"],
2206
+ ["withdrawReason", "TEXT"],
2207
+ ] as const) {
2208
+ if (!reportColumns.some((column) => column.name === name)) {
2209
+ db.exec(`ALTER TABLE reports ADD COLUMN ${name} ${type}`);
2210
+ }
2211
+ }
2132
2212
  // The kind split (#459): a row is either a question a human must answer or a
2133
2213
  // watch the orchestrator set for itself. Databases written before the split
2134
2214
  // never marked it, and every such row was a question — `watch` has no
@@ -3012,7 +3092,7 @@ export function openStore(dbPath: string): Store {
3012
3092
  const selectReport = db.query<ReportRow, [string]>(`SELECT * FROM reports WHERE id = ?`);
3013
3093
  const selectReportByDedupe = db.query<ReportRow, [string, string]>(
3014
3094
  `SELECT * FROM reports
3015
- WHERE project = ? AND dedupeKey = ? AND state <> 'failed'
3095
+ WHERE project = ? AND dedupeKey = ? AND state NOT IN ('failed', 'withdrawn')
3016
3096
  ORDER BY createdAt DESC, rowid DESC LIMIT 1`,
3017
3097
  );
3018
3098
  const selectDueReports = db.query<ReportRow, [string, number, number]>(
@@ -3021,6 +3101,81 @@ export function openStore(dbPath: string): Store {
3021
3101
  ORDER BY createdAt ASC, rowid ASC
3022
3102
  LIMIT ?`,
3023
3103
  );
3104
+ const withdrawPendingReportRow = db.query<
3105
+ unknown,
3106
+ [number, string, string, number, string, string]
3107
+ >(
3108
+ `UPDATE reports
3109
+ SET state = 'withdrawn', withdrawnAt = ?, withdrawnBy = ?,
3110
+ withdrawReason = ?, updatedAt = ?, attemptId = NULL
3111
+ WHERE id = ? AND project = ? AND state = 'pending'`,
3112
+ );
3113
+ const selectReportNoticeIds = db.query<{ id: string }, [string, string]>(
3114
+ `SELECT id FROM held_notices WHERE digestReportId = ? AND project = ?`,
3115
+ );
3116
+ const unassignReportMaterialEvents = db.query<unknown, [string, string]>(
3117
+ `UPDATE material_events SET digestReportId = NULL
3118
+ WHERE digestReportId = ? AND project = ?`,
3119
+ );
3120
+ const markReportMaterialEventsPossibleRepeat = db.query<unknown, [string, string, string]>(
3121
+ `UPDATE material_events
3122
+ SET summary = CASE
3123
+ WHEN summary NOT LIKE 'POSSIBLE REPEAT%' THEN 'POSSIBLE REPEAT of report ' || ? || ': ' || summary
3124
+ ELSE summary
3125
+ END
3126
+ WHERE digestReportId = ? AND project = ?`,
3127
+ );
3128
+ const markReportHeldNoticesPossibleRepeat = db.query<unknown, [string, string, string]>(
3129
+ `UPDATE held_notices
3130
+ SET summary = CASE
3131
+ WHEN summary NOT LIKE 'POSSIBLE REPEAT%' THEN 'POSSIBLE REPEAT of report ' || ? || ': ' || summary
3132
+ ELSE summary
3133
+ END,
3134
+ detail = CASE
3135
+ WHEN detail NOT LIKE 'This handoff may already%' THEN
3136
+ 'This handoff may already have reached Telegram before its outcome was lost.\n\n' || detail
3137
+ ELSE detail
3138
+ END
3139
+ WHERE digestReportId = ? AND project = ? AND withdrawnAt IS NULL`,
3140
+ );
3141
+ const unassignReportHeldNotices = db.query<unknown, [string, string]>(
3142
+ `UPDATE held_notices SET digestReportId = NULL, digestedAt = NULL
3143
+ WHERE digestReportId = ? AND project = ? AND withdrawnAt IS NULL`,
3144
+ );
3145
+ const selectHeldNoticeLifecycle = db.query<HeldNoticeLifecycleRow, [string]>(
3146
+ `SELECT id, project, summary, detail, digestedAt, digestReportId, withdrawnAt
3147
+ FROM held_notices WHERE id = ?`,
3148
+ );
3149
+ const withdrawHeldNoticeRow = db.query<
3150
+ unknown,
3151
+ [number, string, string, string, string]
3152
+ >(
3153
+ `UPDATE held_notices
3154
+ SET withdrawnAt = ?, withdrawnBy = ?, withdrawReason = ?
3155
+ WHERE id = ? AND project = ? AND withdrawnAt IS NULL AND (
3156
+ (digestReportId IS NULL AND digestedAt IS NULL)
3157
+ OR EXISTS (
3158
+ SELECT 1 FROM reports
3159
+ WHERE reports.id = held_notices.digestReportId
3160
+ AND reports.state IN ('failed', 'withdrawn')
3161
+ )
3162
+ )`,
3163
+ );
3164
+ const insertHandoffWithdrawal = db.query<
3165
+ unknown,
3166
+ [string, string, string, string, string, number, string, string]
3167
+ >(
3168
+ `INSERT INTO handoff_withdrawals
3169
+ (id, project, target, actor, reason, at, summary, detail)
3170
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
3171
+ );
3172
+ const selectHandoffWithdrawal = db.query<HandoffWithdrawalRow, [string]>(
3173
+ `SELECT * FROM handoff_withdrawals WHERE id = ?`,
3174
+ );
3175
+ const selectHandoffWithdrawals = db.query<HandoffWithdrawalRow, [string, number]>(
3176
+ `SELECT * FROM handoff_withdrawals
3177
+ WHERE project = ? ORDER BY at DESC, rowid DESC LIMIT ?`,
3178
+ );
3024
3179
  const deletePendingMaterialReport = db.query<unknown, [string, string]>(
3025
3180
  `DELETE FROM reports
3026
3181
  WHERE id = ? AND project = ? AND kind = 'material' AND state = 'pending'`,
@@ -3051,7 +3206,7 @@ export function openStore(dbPath: string): Store {
3051
3206
  THEN 'POSSIBLE REPEAT of catch-up ' || ? || ': ' || summary
3052
3207
  ELSE summary
3053
3208
  END
3054
- WHERE project = ? AND digestReportId = ? AND EXISTS (
3209
+ WHERE project = ? AND digestReportId = ? AND withdrawnAt IS NULL AND EXISTS (
3055
3210
  SELECT 1 FROM reports
3056
3211
  WHERE id = ? AND project = ? AND kind = 'digest' AND state = 'pending'
3057
3212
  AND dedupeKey LIKE 'availability/%'
@@ -3059,12 +3214,13 @@ export function openStore(dbPath: string): Store {
3059
3214
  );
3060
3215
  const selectAvailabilityReservationCandidate = db.query<{ id: string }, [string]>(
3061
3216
  `SELECT held_notices.id FROM held_notices
3062
- WHERE held_notices.project = ? AND held_notices.releaseOnAvailable = 1 AND (
3217
+ WHERE held_notices.project = ? AND held_notices.releaseOnAvailable = 1
3218
+ AND held_notices.withdrawnAt IS NULL AND (
3063
3219
  (held_notices.digestReportId IS NULL AND held_notices.digestedAt IS NULL)
3064
3220
  OR EXISTS (
3065
3221
  SELECT 1 FROM reports
3066
3222
  WHERE reports.id = held_notices.digestReportId AND (
3067
- reports.state = 'failed'
3223
+ reports.state IN ('failed', 'withdrawn')
3068
3224
  OR (
3069
3225
  reports.state = 'pending' AND reports.kind = 'digest'
3070
3226
  AND reports.dedupeKey LIKE 'availability/%'
@@ -3180,9 +3336,9 @@ export function openStore(dbPath: string): Store {
3180
3336
  releaseOnAvailable: number;
3181
3337
  urgent: number;
3182
3338
  };
3183
- const undigestedNoticeWhere = `project = ? AND (
3339
+ const undigestedNoticeWhere = `project = ? AND withdrawnAt IS NULL AND (
3184
3340
  (digestReportId IS NULL AND digestedAt IS NULL)
3185
- OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state = 'failed')
3341
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3186
3342
  )`;
3187
3343
  const selectUndigestedNotices = db.query<HeldNoticeRow, [string, number]>(
3188
3344
  `SELECT id, category, summary, detail, createdAt, releaseOnAvailable, urgent FROM held_notices
@@ -3212,16 +3368,16 @@ export function openStore(dbPath: string): Store {
3212
3368
  MIN(CASE WHEN releaseOnAvailable = 0 THEN createdAt END) AS digestOldestAt,
3213
3369
  MIN(CASE WHEN releaseOnAvailable = 1 THEN createdAt END) AS availabilityOldestAt
3214
3370
  FROM held_notices
3215
- WHERE project = ? AND (
3371
+ WHERE project = ? AND withdrawnAt IS NULL AND (
3216
3372
  (digestReportId IS NULL AND digestedAt IS NULL)
3217
- OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state = 'failed')
3373
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3218
3374
  )`,
3219
3375
  );
3220
3376
  const assignHeldNoticeToDigest = db.query<unknown, [number, string, string, string]>(
3221
3377
  `UPDATE held_notices SET digestedAt = ?, digestReportId = ?
3222
- WHERE project = ? AND id = ? AND (
3378
+ WHERE project = ? AND id = ? AND withdrawnAt IS NULL AND (
3223
3379
  (digestReportId IS NULL AND digestedAt IS NULL)
3224
- OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state = 'failed')
3380
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3225
3381
  )`,
3226
3382
  );
3227
3383
  const insertMaterialEvent = db.query<unknown, SqlValue[]>(
@@ -3236,7 +3392,7 @@ export function openStore(dbPath: string): Store {
3236
3392
  `SELECT * FROM material_events
3237
3393
  WHERE project = ? AND (
3238
3394
  digestReportId IS NULL
3239
- OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state = 'failed')
3395
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3240
3396
  )
3241
3397
  ORDER BY occurredAt ASC, recordedAt ASC, rowid ASC
3242
3398
  LIMIT ?`,
@@ -3245,14 +3401,14 @@ export function openStore(dbPath: string): Store {
3245
3401
  `SELECT COUNT(*) AS count, MIN(occurredAt) AS oldestAt FROM material_events
3246
3402
  WHERE project = ? AND (
3247
3403
  digestReportId IS NULL
3248
- OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state = 'failed')
3404
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3249
3405
  )`,
3250
3406
  );
3251
3407
  const assignMaterialEventToDigest = db.query<unknown, [string, string, string]>(
3252
3408
  `UPDATE material_events SET digestReportId = ?
3253
3409
  WHERE project = ? AND id = ? AND (
3254
3410
  digestReportId IS NULL
3255
- OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state = 'failed')
3411
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3256
3412
  )`,
3257
3413
  );
3258
3414
  const insertIntake = db.query<unknown, SqlValue[]>(
@@ -3278,7 +3434,7 @@ export function openStore(dbPath: string): Store {
3278
3434
  // today's key under `digestDue` (#229).
3279
3435
  const selectLastDigestKey = db.query<{ key: string | null }, [string]>(
3280
3436
  `SELECT dedupeKey AS key FROM reports
3281
- WHERE project = ? AND dedupeKey LIKE 'digest:%' AND state <> 'failed'
3437
+ WHERE project = ? AND dedupeKey LIKE 'digest:%' AND state NOT IN ('failed', 'withdrawn')
3282
3438
  ORDER BY createdAt DESC LIMIT 1`,
3283
3439
  );
3284
3440
  const claimReportRow = db.query<unknown, [string, number, string, number]>(
@@ -3404,6 +3560,109 @@ export function openStore(dbPath: string): Store {
3404
3560
  }
3405
3561
  return enqueued;
3406
3562
  };
3563
+
3564
+ const withdrawHandoffTx = db.transaction(
3565
+ (
3566
+ id: string,
3567
+ project: string,
3568
+ actor: string,
3569
+ reason: string,
3570
+ at: number,
3571
+ ): HandoffWithdrawalResult => {
3572
+ const report = selectReport.get(id);
3573
+ if (report !== null) {
3574
+ if (report.project !== project) return { kind: "not-found" };
3575
+ if (report.state !== "pending") {
3576
+ return { kind: "refused", target: "report", state: report.state };
3577
+ }
3578
+ const noticeIds = selectReportNoticeIds.all(id, project).map((row) => row.id);
3579
+ if (withdrawPendingReportRow.run(at, actor, reason, at, id, project).changes !== 1) {
3580
+ const current = selectReport.get(id);
3581
+ return {
3582
+ kind: "refused",
3583
+ target: "report",
3584
+ state: current?.state ?? "not-found",
3585
+ };
3586
+ }
3587
+ if (report.ambiguous !== 0) {
3588
+ markReportMaterialEventsPossibleRepeat.run(id, id, project);
3589
+ markReportHeldNoticesPossibleRepeat.run(id, id, project);
3590
+ }
3591
+ unassignReportMaterialEvents.run(id, project);
3592
+ unassignReportHeldNotices.run(id, project);
3593
+ for (const noticeId of noticeIds) {
3594
+ deleteAvailabilityReservationForNotice.run(project, noticeId);
3595
+ }
3596
+ const summary = report.body.split("\n", 1)[0]!.slice(0, 240);
3597
+ const withdrawal: HandoffWithdrawal = {
3598
+ id,
3599
+ project,
3600
+ target: "report",
3601
+ actor,
3602
+ reason,
3603
+ at,
3604
+ summary,
3605
+ detail: report.body,
3606
+ };
3607
+ insertHandoffWithdrawal.run(
3608
+ id,
3609
+ project,
3610
+ "report",
3611
+ actor,
3612
+ reason,
3613
+ at,
3614
+ summary,
3615
+ report.body,
3616
+ );
3617
+ return { kind: "withdrawn", withdrawal };
3618
+ }
3619
+
3620
+ const prior = selectHandoffWithdrawal.get(id);
3621
+ if (prior !== null) {
3622
+ return prior.project === project
3623
+ ? { kind: "refused", target: prior.target as HandoffWithdrawal["target"], state: "withdrawn" }
3624
+ : { kind: "not-found" };
3625
+ }
3626
+ const notice = selectHeldNoticeLifecycle.get(id);
3627
+ if (notice === null || notice.project !== project) return { kind: "not-found" };
3628
+ if (notice.withdrawnAt !== null) {
3629
+ return { kind: "refused", target: "notice", state: "withdrawn" };
3630
+ }
3631
+ if (notice.digestReportId !== null) {
3632
+ const owner = selectReport.get(notice.digestReportId);
3633
+ if (owner === null || (owner.state !== "failed" && owner.state !== "withdrawn")) {
3634
+ return { kind: "refused", target: "notice", state: "assigned" };
3635
+ }
3636
+ } else if (notice.digestedAt !== null) {
3637
+ return { kind: "refused", target: "notice", state: "digested" };
3638
+ }
3639
+ if (withdrawHeldNoticeRow.run(at, actor, reason, id, project).changes !== 1) {
3640
+ return { kind: "refused", target: "notice", state: "changed" };
3641
+ }
3642
+ deleteAvailabilityReservationForNotice.run(project, id);
3643
+ const withdrawal: HandoffWithdrawal = {
3644
+ id,
3645
+ project,
3646
+ target: "notice",
3647
+ actor,
3648
+ reason,
3649
+ at,
3650
+ summary: notice.summary,
3651
+ detail: notice.detail,
3652
+ };
3653
+ insertHandoffWithdrawal.run(
3654
+ id,
3655
+ project,
3656
+ "notice",
3657
+ actor,
3658
+ reason,
3659
+ at,
3660
+ notice.summary,
3661
+ notice.detail,
3662
+ );
3663
+ return { kind: "withdrawn", withdrawal };
3664
+ },
3665
+ );
3407
3666
  const enqueueDigestReportTx = db.transaction(enqueueDigestReportRecord);
3408
3667
  const enqueueAvailabilityReportTx = db.transaction(
3409
3668
  (draft: ReportDraft, heldNoticeIds: readonly string[]): ReportEnqueue | undefined => {
@@ -4870,6 +5129,21 @@ export function openStore(dbPath: string): Store {
4870
5129
  return row === null ? undefined : toReport(row);
4871
5130
  },
4872
5131
 
5132
+ withdrawHandoff(
5133
+ id: string,
5134
+ project: string,
5135
+ actor: string,
5136
+ reason: string,
5137
+ at: number,
5138
+ ): HandoffWithdrawalResult {
5139
+ return withdrawHandoffTx.immediate(id, project, actor, reason, at);
5140
+ },
5141
+
5142
+ handoffWithdrawals(project: string, limit = 5): HandoffWithdrawal[] {
5143
+ return selectHandoffWithdrawals.all(project, limit).map(toHandoffWithdrawal);
5144
+ },
5145
+
5146
+
4873
5147
  deferPendingReportToNotice(id: string, notice: HeldNoticeDraft): boolean {
4874
5148
  return deferPendingReportToNoticeTx(id, notice);
4875
5149
  },