omp-conductor 0.19.5 → 0.19.7

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.
@@ -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;
@@ -1310,6 +1356,20 @@ CREATE TABLE IF NOT EXISTS orchestrator_incidents (
1310
1356
  diverted INTEGER NOT NULL DEFAULT 0
1311
1357
  );
1312
1358
 
1359
+ -- Conductor-owned Herdr worker workspaces (#1035 review). Herdr restores a
1360
+ -- session's workspaces and panes across a server restart but drops
1361
+ -- report-metadata tokens, so the live-only token cannot remain the only
1362
+ -- ownership authority: the restored surface would be invisible to discovery,
1363
+ -- a duplicate would be created beside it, and its stale panes could never be
1364
+ -- reconciled or removed. One row per workspace conductor created and still
1365
+ -- owns; forgotten when the workspace is removed or fails to create cleanly.
1366
+ CREATE TABLE IF NOT EXISTS herdr_worker_workspaces (
1367
+ project TEXT NOT NULL,
1368
+ workspace_id TEXT NOT NULL,
1369
+ created_at INTEGER NOT NULL,
1370
+ PRIMARY KEY (project, workspace_id)
1371
+ );
1372
+
1313
1373
  -- Every stop/restart of the shared daemon, and who asked for it and why
1314
1374
  -- (#378). Deliberately NOT partitioned by project: the daemon serves every
1315
1375
  -- configured project, so a record written by one project's CLI must be
@@ -1562,9 +1622,25 @@ function toReport(row: ReportRow): ReportRecord {
1562
1622
  if (row.sentPartsHash !== null) record.sentPartsHash = row.sentPartsHash;
1563
1623
  if (row.deliveredAt !== null) record.deliveredAt = row.deliveredAt;
1564
1624
  if (row.lastError !== null) record.lastError = row.lastError;
1625
+ if (row.withdrawnAt !== null) record.withdrawnAt = row.withdrawnAt;
1626
+ if (row.withdrawnBy !== null) record.withdrawnBy = row.withdrawnBy;
1627
+ if (row.withdrawReason !== null) record.withdrawReason = row.withdrawReason;
1565
1628
  return record;
1566
1629
  }
1567
1630
 
1631
+ function toHandoffWithdrawal(row: HandoffWithdrawalRow): HandoffWithdrawal {
1632
+ return {
1633
+ id: row.id,
1634
+ project: row.project,
1635
+ summary: row.summary,
1636
+ detail: row.detail,
1637
+ target: row.target as HandoffWithdrawal["target"],
1638
+ actor: row.actor,
1639
+ reason: row.reason,
1640
+ at: row.at,
1641
+ };
1642
+ }
1643
+
1568
1644
  function toMaterialEvent(row: MaterialEventRow): MaterialEvent {
1569
1645
  return {
1570
1646
  id: row.id,
@@ -1905,20 +1981,20 @@ export function openStore(dbPath: string): Store {
1905
1981
  db.exec("PRAGMA journal_mode = WAL;");
1906
1982
  db.exec("PRAGMA foreign_keys = ON;");
1907
1983
  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.
1984
+ // Failed or explicitly withdrawn handoffs no longer own a digest dedupe key:
1985
+ // both leave their underlying ledger rows owed for a replacement report.
1911
1986
  const reportDedupeIndex = db
1912
1987
  .query<{ sql: string | null }, []>(
1913
1988
  `SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'reports_dedupe'`,
1914
1989
  )
1915
1990
  .get();
1916
- if (!(reportDedupeIndex?.sql ?? "").includes("state <> 'failed'")) {
1991
+ if (!(reportDedupeIndex?.sql ?? "").includes("state <> 'withdrawn'")) {
1917
1992
  db.exec(
1918
1993
  `BEGIN IMMEDIATE;
1919
1994
  DROP INDEX IF EXISTS reports_dedupe;
1920
1995
  CREATE UNIQUE INDEX reports_dedupe
1921
- ON reports (project, dedupeKey) WHERE dedupeKey IS NOT NULL AND state <> 'failed';
1996
+ ON reports (project, dedupeKey)
1997
+ WHERE dedupeKey IS NOT NULL AND state <> 'failed' AND state <> 'withdrawn';
1922
1998
  COMMIT;`,
1923
1999
  );
1924
2000
  }
@@ -2112,6 +2188,15 @@ export function openStore(dbPath: string): Store {
2112
2188
  }
2113
2189
  // Urgent recovery notices bypass category batching only; they still wait for
2114
2190
  // the configured availability window. Historical notices were not urgent.
2191
+ for (const [name, type] of [
2192
+ ["withdrawnAt", "INTEGER"],
2193
+ ["withdrawnBy", "TEXT"],
2194
+ ["withdrawReason", "TEXT"],
2195
+ ] as const) {
2196
+ if (!heldNoticeColumns.some((column) => column.name === name)) {
2197
+ db.exec(`ALTER TABLE held_notices ADD COLUMN ${name} ${type}`);
2198
+ }
2199
+ }
2115
2200
  if (!heldNoticeColumns.some((column) => column.name === "urgent")) {
2116
2201
  db.exec("ALTER TABLE held_notices ADD COLUMN urgent INTEGER NOT NULL DEFAULT 0");
2117
2202
  }
@@ -2129,6 +2214,15 @@ export function openStore(dbPath: string): Store {
2129
2214
  if (!reportColumns.some((column) => column.name === "sentPartsHash")) {
2130
2215
  db.exec("ALTER TABLE reports ADD COLUMN sentPartsHash TEXT");
2131
2216
  }
2217
+ for (const [name, type] of [
2218
+ ["withdrawnAt", "INTEGER"],
2219
+ ["withdrawnBy", "TEXT"],
2220
+ ["withdrawReason", "TEXT"],
2221
+ ] as const) {
2222
+ if (!reportColumns.some((column) => column.name === name)) {
2223
+ db.exec(`ALTER TABLE reports ADD COLUMN ${name} ${type}`);
2224
+ }
2225
+ }
2132
2226
  // The kind split (#459): a row is either a question a human must answer or a
2133
2227
  // watch the orchestrator set for itself. Databases written before the split
2134
2228
  // never marked it, and every such row was a question — `watch` has no
@@ -2874,6 +2968,18 @@ export function openStore(dbPath: string): Store {
2874
2968
  WHERE project = ?
2875
2969
  RETURNING project, mode, cause, since, diverted`,
2876
2970
  );
2971
+ // Worker-workspace ownership rows (#1035 review). See the table comment:
2972
+ // the durable half of discovery, so a Herdr restart that drops metadata
2973
+ // tokens cannot orphan the surface conductor created.
2974
+ const selectHerdrWorkerWorkspaces = db.query<{ workspace_id: string }, [string]>(
2975
+ `SELECT workspace_id FROM herdr_worker_workspaces WHERE project = ? ORDER BY created_at ASC, workspace_id ASC`,
2976
+ );
2977
+ const insertHerdrWorkerWorkspace = db.query<unknown, [string, string, number]>(
2978
+ `INSERT OR IGNORE INTO herdr_worker_workspaces (project, workspace_id, created_at) VALUES (?, ?, ?)`,
2979
+ );
2980
+ const deleteHerdrWorkerWorkspace = db.query<unknown, [string, string]>(
2981
+ `DELETE FROM herdr_worker_workspaces WHERE project = ? AND workspace_id = ?`,
2982
+ );
2877
2983
  const upsertDispatch = db.query<unknown, [string, string]>(
2878
2984
  `INSERT INTO dispatch_summaries (project, summary) VALUES (?, ?)
2879
2985
  ON CONFLICT(project) DO UPDATE SET summary = excluded.summary`,
@@ -3012,7 +3118,7 @@ export function openStore(dbPath: string): Store {
3012
3118
  const selectReport = db.query<ReportRow, [string]>(`SELECT * FROM reports WHERE id = ?`);
3013
3119
  const selectReportByDedupe = db.query<ReportRow, [string, string]>(
3014
3120
  `SELECT * FROM reports
3015
- WHERE project = ? AND dedupeKey = ? AND state <> 'failed'
3121
+ WHERE project = ? AND dedupeKey = ? AND state NOT IN ('failed', 'withdrawn')
3016
3122
  ORDER BY createdAt DESC, rowid DESC LIMIT 1`,
3017
3123
  );
3018
3124
  const selectDueReports = db.query<ReportRow, [string, number, number]>(
@@ -3021,6 +3127,81 @@ export function openStore(dbPath: string): Store {
3021
3127
  ORDER BY createdAt ASC, rowid ASC
3022
3128
  LIMIT ?`,
3023
3129
  );
3130
+ const withdrawPendingReportRow = db.query<
3131
+ unknown,
3132
+ [number, string, string, number, string, string]
3133
+ >(
3134
+ `UPDATE reports
3135
+ SET state = 'withdrawn', withdrawnAt = ?, withdrawnBy = ?,
3136
+ withdrawReason = ?, updatedAt = ?, attemptId = NULL
3137
+ WHERE id = ? AND project = ? AND state = 'pending'`,
3138
+ );
3139
+ const selectReportNoticeIds = db.query<{ id: string }, [string, string]>(
3140
+ `SELECT id FROM held_notices WHERE digestReportId = ? AND project = ?`,
3141
+ );
3142
+ const unassignReportMaterialEvents = db.query<unknown, [string, string]>(
3143
+ `UPDATE material_events SET digestReportId = NULL
3144
+ WHERE digestReportId = ? AND project = ?`,
3145
+ );
3146
+ const markReportMaterialEventsPossibleRepeat = db.query<unknown, [string, string, string]>(
3147
+ `UPDATE material_events
3148
+ SET summary = CASE
3149
+ WHEN summary NOT LIKE 'POSSIBLE REPEAT%' THEN 'POSSIBLE REPEAT of report ' || ? || ': ' || summary
3150
+ ELSE summary
3151
+ END
3152
+ WHERE digestReportId = ? AND project = ?`,
3153
+ );
3154
+ const markReportHeldNoticesPossibleRepeat = db.query<unknown, [string, string, string]>(
3155
+ `UPDATE held_notices
3156
+ SET summary = CASE
3157
+ WHEN summary NOT LIKE 'POSSIBLE REPEAT%' THEN 'POSSIBLE REPEAT of report ' || ? || ': ' || summary
3158
+ ELSE summary
3159
+ END,
3160
+ detail = CASE
3161
+ WHEN detail NOT LIKE 'This handoff may already%' THEN
3162
+ 'This handoff may already have reached Telegram before its outcome was lost.\n\n' || detail
3163
+ ELSE detail
3164
+ END
3165
+ WHERE digestReportId = ? AND project = ? AND withdrawnAt IS NULL`,
3166
+ );
3167
+ const unassignReportHeldNotices = db.query<unknown, [string, string]>(
3168
+ `UPDATE held_notices SET digestReportId = NULL, digestedAt = NULL
3169
+ WHERE digestReportId = ? AND project = ? AND withdrawnAt IS NULL`,
3170
+ );
3171
+ const selectHeldNoticeLifecycle = db.query<HeldNoticeLifecycleRow, [string]>(
3172
+ `SELECT id, project, summary, detail, digestedAt, digestReportId, withdrawnAt
3173
+ FROM held_notices WHERE id = ?`,
3174
+ );
3175
+ const withdrawHeldNoticeRow = db.query<
3176
+ unknown,
3177
+ [number, string, string, string, string]
3178
+ >(
3179
+ `UPDATE held_notices
3180
+ SET withdrawnAt = ?, withdrawnBy = ?, withdrawReason = ?
3181
+ WHERE id = ? AND project = ? AND withdrawnAt IS NULL AND (
3182
+ (digestReportId IS NULL AND digestedAt IS NULL)
3183
+ OR EXISTS (
3184
+ SELECT 1 FROM reports
3185
+ WHERE reports.id = held_notices.digestReportId
3186
+ AND reports.state IN ('failed', 'withdrawn')
3187
+ )
3188
+ )`,
3189
+ );
3190
+ const insertHandoffWithdrawal = db.query<
3191
+ unknown,
3192
+ [string, string, string, string, string, number, string, string]
3193
+ >(
3194
+ `INSERT INTO handoff_withdrawals
3195
+ (id, project, target, actor, reason, at, summary, detail)
3196
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
3197
+ );
3198
+ const selectHandoffWithdrawal = db.query<HandoffWithdrawalRow, [string]>(
3199
+ `SELECT * FROM handoff_withdrawals WHERE id = ?`,
3200
+ );
3201
+ const selectHandoffWithdrawals = db.query<HandoffWithdrawalRow, [string, number]>(
3202
+ `SELECT * FROM handoff_withdrawals
3203
+ WHERE project = ? ORDER BY at DESC, rowid DESC LIMIT ?`,
3204
+ );
3024
3205
  const deletePendingMaterialReport = db.query<unknown, [string, string]>(
3025
3206
  `DELETE FROM reports
3026
3207
  WHERE id = ? AND project = ? AND kind = 'material' AND state = 'pending'`,
@@ -3051,7 +3232,7 @@ export function openStore(dbPath: string): Store {
3051
3232
  THEN 'POSSIBLE REPEAT of catch-up ' || ? || ': ' || summary
3052
3233
  ELSE summary
3053
3234
  END
3054
- WHERE project = ? AND digestReportId = ? AND EXISTS (
3235
+ WHERE project = ? AND digestReportId = ? AND withdrawnAt IS NULL AND EXISTS (
3055
3236
  SELECT 1 FROM reports
3056
3237
  WHERE id = ? AND project = ? AND kind = 'digest' AND state = 'pending'
3057
3238
  AND dedupeKey LIKE 'availability/%'
@@ -3059,12 +3240,13 @@ export function openStore(dbPath: string): Store {
3059
3240
  );
3060
3241
  const selectAvailabilityReservationCandidate = db.query<{ id: string }, [string]>(
3061
3242
  `SELECT held_notices.id FROM held_notices
3062
- WHERE held_notices.project = ? AND held_notices.releaseOnAvailable = 1 AND (
3243
+ WHERE held_notices.project = ? AND held_notices.releaseOnAvailable = 1
3244
+ AND held_notices.withdrawnAt IS NULL AND (
3063
3245
  (held_notices.digestReportId IS NULL AND held_notices.digestedAt IS NULL)
3064
3246
  OR EXISTS (
3065
3247
  SELECT 1 FROM reports
3066
3248
  WHERE reports.id = held_notices.digestReportId AND (
3067
- reports.state = 'failed'
3249
+ reports.state IN ('failed', 'withdrawn')
3068
3250
  OR (
3069
3251
  reports.state = 'pending' AND reports.kind = 'digest'
3070
3252
  AND reports.dedupeKey LIKE 'availability/%'
@@ -3180,9 +3362,9 @@ export function openStore(dbPath: string): Store {
3180
3362
  releaseOnAvailable: number;
3181
3363
  urgent: number;
3182
3364
  };
3183
- const undigestedNoticeWhere = `project = ? AND (
3365
+ const undigestedNoticeWhere = `project = ? AND withdrawnAt IS NULL AND (
3184
3366
  (digestReportId IS NULL AND digestedAt IS NULL)
3185
- OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state = 'failed')
3367
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3186
3368
  )`;
3187
3369
  const selectUndigestedNotices = db.query<HeldNoticeRow, [string, number]>(
3188
3370
  `SELECT id, category, summary, detail, createdAt, releaseOnAvailable, urgent FROM held_notices
@@ -3212,16 +3394,16 @@ export function openStore(dbPath: string): Store {
3212
3394
  MIN(CASE WHEN releaseOnAvailable = 0 THEN createdAt END) AS digestOldestAt,
3213
3395
  MIN(CASE WHEN releaseOnAvailable = 1 THEN createdAt END) AS availabilityOldestAt
3214
3396
  FROM held_notices
3215
- WHERE project = ? AND (
3397
+ WHERE project = ? AND withdrawnAt IS NULL AND (
3216
3398
  (digestReportId IS NULL AND digestedAt IS NULL)
3217
- OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state = 'failed')
3399
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3218
3400
  )`,
3219
3401
  );
3220
3402
  const assignHeldNoticeToDigest = db.query<unknown, [number, string, string, string]>(
3221
3403
  `UPDATE held_notices SET digestedAt = ?, digestReportId = ?
3222
- WHERE project = ? AND id = ? AND (
3404
+ WHERE project = ? AND id = ? AND withdrawnAt IS NULL AND (
3223
3405
  (digestReportId IS NULL AND digestedAt IS NULL)
3224
- OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state = 'failed')
3406
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3225
3407
  )`,
3226
3408
  );
3227
3409
  const insertMaterialEvent = db.query<unknown, SqlValue[]>(
@@ -3236,7 +3418,7 @@ export function openStore(dbPath: string): Store {
3236
3418
  `SELECT * FROM material_events
3237
3419
  WHERE project = ? AND (
3238
3420
  digestReportId IS NULL
3239
- OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state = 'failed')
3421
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3240
3422
  )
3241
3423
  ORDER BY occurredAt ASC, recordedAt ASC, rowid ASC
3242
3424
  LIMIT ?`,
@@ -3245,14 +3427,14 @@ export function openStore(dbPath: string): Store {
3245
3427
  `SELECT COUNT(*) AS count, MIN(occurredAt) AS oldestAt FROM material_events
3246
3428
  WHERE project = ? AND (
3247
3429
  digestReportId IS NULL
3248
- OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state = 'failed')
3430
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3249
3431
  )`,
3250
3432
  );
3251
3433
  const assignMaterialEventToDigest = db.query<unknown, [string, string, string]>(
3252
3434
  `UPDATE material_events SET digestReportId = ?
3253
3435
  WHERE project = ? AND id = ? AND (
3254
3436
  digestReportId IS NULL
3255
- OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state = 'failed')
3437
+ OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
3256
3438
  )`,
3257
3439
  );
3258
3440
  const insertIntake = db.query<unknown, SqlValue[]>(
@@ -3278,7 +3460,7 @@ export function openStore(dbPath: string): Store {
3278
3460
  // today's key under `digestDue` (#229).
3279
3461
  const selectLastDigestKey = db.query<{ key: string | null }, [string]>(
3280
3462
  `SELECT dedupeKey AS key FROM reports
3281
- WHERE project = ? AND dedupeKey LIKE 'digest:%' AND state <> 'failed'
3463
+ WHERE project = ? AND dedupeKey LIKE 'digest:%' AND state NOT IN ('failed', 'withdrawn')
3282
3464
  ORDER BY createdAt DESC LIMIT 1`,
3283
3465
  );
3284
3466
  const claimReportRow = db.query<unknown, [string, number, string, number]>(
@@ -3404,6 +3586,109 @@ export function openStore(dbPath: string): Store {
3404
3586
  }
3405
3587
  return enqueued;
3406
3588
  };
3589
+
3590
+ const withdrawHandoffTx = db.transaction(
3591
+ (
3592
+ id: string,
3593
+ project: string,
3594
+ actor: string,
3595
+ reason: string,
3596
+ at: number,
3597
+ ): HandoffWithdrawalResult => {
3598
+ const report = selectReport.get(id);
3599
+ if (report !== null) {
3600
+ if (report.project !== project) return { kind: "not-found" };
3601
+ if (report.state !== "pending") {
3602
+ return { kind: "refused", target: "report", state: report.state };
3603
+ }
3604
+ const noticeIds = selectReportNoticeIds.all(id, project).map((row) => row.id);
3605
+ if (withdrawPendingReportRow.run(at, actor, reason, at, id, project).changes !== 1) {
3606
+ const current = selectReport.get(id);
3607
+ return {
3608
+ kind: "refused",
3609
+ target: "report",
3610
+ state: current?.state ?? "not-found",
3611
+ };
3612
+ }
3613
+ if (report.ambiguous !== 0) {
3614
+ markReportMaterialEventsPossibleRepeat.run(id, id, project);
3615
+ markReportHeldNoticesPossibleRepeat.run(id, id, project);
3616
+ }
3617
+ unassignReportMaterialEvents.run(id, project);
3618
+ unassignReportHeldNotices.run(id, project);
3619
+ for (const noticeId of noticeIds) {
3620
+ deleteAvailabilityReservationForNotice.run(project, noticeId);
3621
+ }
3622
+ const summary = report.body.split("\n", 1)[0]!.slice(0, 240);
3623
+ const withdrawal: HandoffWithdrawal = {
3624
+ id,
3625
+ project,
3626
+ target: "report",
3627
+ actor,
3628
+ reason,
3629
+ at,
3630
+ summary,
3631
+ detail: report.body,
3632
+ };
3633
+ insertHandoffWithdrawal.run(
3634
+ id,
3635
+ project,
3636
+ "report",
3637
+ actor,
3638
+ reason,
3639
+ at,
3640
+ summary,
3641
+ report.body,
3642
+ );
3643
+ return { kind: "withdrawn", withdrawal };
3644
+ }
3645
+
3646
+ const prior = selectHandoffWithdrawal.get(id);
3647
+ if (prior !== null) {
3648
+ return prior.project === project
3649
+ ? { kind: "refused", target: prior.target as HandoffWithdrawal["target"], state: "withdrawn" }
3650
+ : { kind: "not-found" };
3651
+ }
3652
+ const notice = selectHeldNoticeLifecycle.get(id);
3653
+ if (notice === null || notice.project !== project) return { kind: "not-found" };
3654
+ if (notice.withdrawnAt !== null) {
3655
+ return { kind: "refused", target: "notice", state: "withdrawn" };
3656
+ }
3657
+ if (notice.digestReportId !== null) {
3658
+ const owner = selectReport.get(notice.digestReportId);
3659
+ if (owner === null || (owner.state !== "failed" && owner.state !== "withdrawn")) {
3660
+ return { kind: "refused", target: "notice", state: "assigned" };
3661
+ }
3662
+ } else if (notice.digestedAt !== null) {
3663
+ return { kind: "refused", target: "notice", state: "digested" };
3664
+ }
3665
+ if (withdrawHeldNoticeRow.run(at, actor, reason, id, project).changes !== 1) {
3666
+ return { kind: "refused", target: "notice", state: "changed" };
3667
+ }
3668
+ deleteAvailabilityReservationForNotice.run(project, id);
3669
+ const withdrawal: HandoffWithdrawal = {
3670
+ id,
3671
+ project,
3672
+ target: "notice",
3673
+ actor,
3674
+ reason,
3675
+ at,
3676
+ summary: notice.summary,
3677
+ detail: notice.detail,
3678
+ };
3679
+ insertHandoffWithdrawal.run(
3680
+ id,
3681
+ project,
3682
+ "notice",
3683
+ actor,
3684
+ reason,
3685
+ at,
3686
+ notice.summary,
3687
+ notice.detail,
3688
+ );
3689
+ return { kind: "withdrawn", withdrawal };
3690
+ },
3691
+ );
3407
3692
  const enqueueDigestReportTx = db.transaction(enqueueDigestReportRecord);
3408
3693
  const enqueueAvailabilityReportTx = db.transaction(
3409
3694
  (draft: ReportDraft, heldNoticeIds: readonly string[]): ReportEnqueue | undefined => {
@@ -4590,6 +4875,22 @@ export function openStore(dbPath: string): Store {
4590
4875
  bumpOrchestratorIncident.run(by, project);
4591
4876
  },
4592
4877
 
4878
+ /** The workspace ids conductor recorded as its own worker surface for
4879
+ * this project, oldest first (#1035 review). Durable across daemon AND
4880
+ * Herdr restarts, which is the point: Herdr restores workspaces without
4881
+ * their metadata tokens, so discovery needs a second authority. */
4882
+ workerWorkspaceIds(project: string): string[] {
4883
+ return selectHerdrWorkerWorkspaces.all(project).map((row) => row.workspace_id);
4884
+ },
4885
+
4886
+ rememberWorkerWorkspace(project: string, workspaceId: string): void {
4887
+ insertHerdrWorkerWorkspace.run(project, workspaceId, Date.now());
4888
+ },
4889
+
4890
+ forgetWorkerWorkspace(project: string, workspaceId: string): void {
4891
+ deleteHerdrWorkerWorkspace.run(project, workspaceId);
4892
+ },
4893
+
4593
4894
  closeOrchestratorIncident(project: string, _at: number): OrchestratorIncident | undefined {
4594
4895
  const row = deleteOrchestratorIncident.get(project);
4595
4896
  return row === null ? undefined : toOrchestratorIncident(row);
@@ -4870,6 +5171,21 @@ export function openStore(dbPath: string): Store {
4870
5171
  return row === null ? undefined : toReport(row);
4871
5172
  },
4872
5173
 
5174
+ withdrawHandoff(
5175
+ id: string,
5176
+ project: string,
5177
+ actor: string,
5178
+ reason: string,
5179
+ at: number,
5180
+ ): HandoffWithdrawalResult {
5181
+ return withdrawHandoffTx.immediate(id, project, actor, reason, at);
5182
+ },
5183
+
5184
+ handoffWithdrawals(project: string, limit = 5): HandoffWithdrawal[] {
5185
+ return selectHandoffWithdrawals.all(project, limit).map(toHandoffWithdrawal);
5186
+ },
5187
+
5188
+
4873
5189
  deferPendingReportToNotice(id: string, notice: HeldNoticeDraft): boolean {
4874
5190
  return deferPendingReportToNoticeTx(id, notice);
4875
5191
  },