agent-relay-server 0.107.0 → 0.108.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 (143) hide show
  1. package/docs/openapi.json +475 -1
  2. package/package.json +4 -4
  3. package/public/assets/{MessageBubble-PEdvVh_m.js → MessageBubble-DYJezSjU.js} +2 -2
  4. package/public/assets/{MessageBubble-PEdvVh_m.js.map → MessageBubble-DYJezSjU.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-DSts7Rtq.js → PlanGraphPanel-MWcVw38W.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-DSts7Rtq.js.map → PlanGraphPanel-MWcVw38W.js.map} +1 -1
  7. package/public/assets/{activity-BcYf8YNO.js → activity-Db3X3dg8.js} +2 -2
  8. package/public/assets/{activity-BcYf8YNO.js.map → activity-Db3X3dg8.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CuTQtmV6.js → agent-profiles-Di2w5U1o.js} +2 -2
  10. package/public/assets/{agent-profiles-CuTQtmV6.js.map → agent-profiles-Di2w5U1o.js.map} +1 -1
  11. package/public/assets/{agents-CgRFJyks.js → agents-GSJXvmU1.js} +2 -2
  12. package/public/assets/{agents-CgRFJyks.js.map → agents-GSJXvmU1.js.map} +1 -1
  13. package/public/assets/{analytics-C3OTFIXo.js → analytics-B9USQLl5.js} +2 -2
  14. package/public/assets/{analytics-C3OTFIXo.js.map → analytics-B9USQLl5.js.map} +1 -1
  15. package/public/assets/{automation-CH9YGYoE.js → automation-XRePs1dT.js} +2 -2
  16. package/public/assets/{automation-CH9YGYoE.js.map → automation-XRePs1dT.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-CcLZ_u86.js → branch-state-badge-C_VNyER3.js} +2 -2
  18. package/public/assets/{branch-state-badge-CcLZ_u86.js.map → branch-state-badge-C_VNyER3.js.map} +1 -1
  19. package/public/assets/{channels-CcSnLzCG.js → channels-DACvNoC2.js} +2 -2
  20. package/public/assets/{channels-CcSnLzCG.js.map → channels-DACvNoC2.js.map} +1 -1
  21. package/public/assets/{chat-BLcm2Aht.js → chat-D_0uHgB6.js} +2 -2
  22. package/public/assets/{chat-BLcm2Aht.js.map → chat-D_0uHgB6.js.map} +1 -1
  23. package/public/assets/{connectors-DbPOlQhL.js → connectors-C0B38LUK.js} +2 -2
  24. package/public/assets/{connectors-DbPOlQhL.js.map → connectors-C0B38LUK.js.map} +1 -1
  25. package/public/assets/display-1J__Ti1K.js +3 -0
  26. package/public/assets/display-1J__Ti1K.js.map +1 -0
  27. package/public/assets/{formatted-body-impl-BJTthVHg.js → formatted-body-impl-BKWCAjBd.js} +2 -2
  28. package/public/assets/{formatted-body-impl-BJTthVHg.js.map → formatted-body-impl-BKWCAjBd.js.map} +1 -1
  29. package/public/assets/index-DlBSuzd2.js +23 -0
  30. package/public/assets/index-DlBSuzd2.js.map +1 -0
  31. package/public/assets/{insights-DklrIhb6.js → insights-BzNoYvDe.js} +2 -2
  32. package/public/assets/{insights-DklrIhb6.js.map → insights-BzNoYvDe.js.map} +1 -1
  33. package/public/assets/{integrations-hkSW8CzM.js → integrations-DLzJpt9G.js} +2 -2
  34. package/public/assets/{integrations-hkSW8CzM.js.map → integrations-DLzJpt9G.js.map} +1 -1
  35. package/public/assets/{maintenance-FAAMGpuZ.js → maintenance-B6_0-ECm.js} +2 -2
  36. package/public/assets/{maintenance-FAAMGpuZ.js.map → maintenance-B6_0-ECm.js.map} +1 -1
  37. package/public/assets/{managed-agents-CVca-s3t.js → managed-agents-CpzKXDtV.js} +2 -2
  38. package/public/assets/{managed-agents-CVca-s3t.js.map → managed-agents-CpzKXDtV.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-BPW4OQo9.js → markdown-preview-impl-DxBnlJ2k.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-BPW4OQo9.js.map → markdown-preview-impl-DxBnlJ2k.js.map} +1 -1
  41. package/public/assets/{memory-BzuDTHC5.js → memory-Bnm7ILWL.js} +2 -2
  42. package/public/assets/{memory-BzuDTHC5.js.map → memory-Bnm7ILWL.js.map} +1 -1
  43. package/public/assets/{messages-_W24PNvT.js → messages-D9LkocTc.js} +2 -2
  44. package/public/assets/{messages-_W24PNvT.js.map → messages-D9LkocTc.js.map} +1 -1
  45. package/public/assets/{orchestrators-Du6I_n8i.js → orchestrators-C__JfrNr.js} +2 -2
  46. package/public/assets/{orchestrators-Du6I_n8i.js.map → orchestrators-C__JfrNr.js.map} +1 -1
  47. package/public/assets/{overview-Dyvm1qpT.js → overview-BQ0b0V7Q.js} +2 -2
  48. package/public/assets/{overview-Dyvm1qpT.js.map → overview-BQ0b0V7Q.js.map} +1 -1
  49. package/public/assets/{pairs-B8x1RCNd.js → pairs-ChyeBs0Z.js} +2 -2
  50. package/public/assets/{pairs-B8x1RCNd.js.map → pairs-ChyeBs0Z.js.map} +1 -1
  51. package/public/assets/{projects-vt63bwHN.js → projects-v5Kp1Oaq.js} +2 -2
  52. package/public/assets/{projects-vt63bwHN.js.map → projects-v5Kp1Oaq.js.map} +1 -1
  53. package/public/assets/{prompt-settings-xVXmvxB5.js → prompt-settings-YhAOlYc6.js} +2 -2
  54. package/public/assets/{prompt-settings-xVXmvxB5.js.map → prompt-settings-YhAOlYc6.js.map} +1 -1
  55. package/public/assets/{registry-ypiICauZ.js → registry-DZoAfrzx.js} +2 -2
  56. package/public/assets/{registry-ypiICauZ.js.map → registry-DZoAfrzx.js.map} +1 -1
  57. package/public/assets/{security-BfOQ6T5r.js → security-DRCj00eA.js} +2 -2
  58. package/public/assets/{security-BfOQ6T5r.js.map → security-DRCj00eA.js.map} +1 -1
  59. package/public/assets/{select-Dx8Ro-A3.js → select-ZOpLw2Fd.js} +2 -2
  60. package/public/assets/{select-Dx8Ro-A3.js.map → select-ZOpLw2Fd.js.map} +1 -1
  61. package/public/assets/{settings-87clTx4N.js → settings-DGgwr9WH.js} +2 -2
  62. package/public/assets/{settings-87clTx4N.js.map → settings-DGgwr9WH.js.map} +1 -1
  63. package/public/assets/{store-DUfrGvOE.js → store-Bnag1kY-.js} +2 -2
  64. package/public/assets/{store-DUfrGvOE.js.map → store-Bnag1kY-.js.map} +1 -1
  65. package/public/assets/{tasks-1Kvp53wh.js → tasks-DUTu8ZhA.js} +2 -2
  66. package/public/assets/{tasks-1Kvp53wh.js.map → tasks-DUTu8ZhA.js.map} +1 -1
  67. package/public/assets/{teams-DJWp4AKi.js → teams-BJpaoG2m.js} +2 -2
  68. package/public/assets/{teams-DJWp4AKi.js.map → teams-BJpaoG2m.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-DgMuRAe_.js → terminal-viewer-impl-CQhh6oXa.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-DgMuRAe_.js.map → terminal-viewer-impl-CQhh6oXa.js.map} +1 -1
  71. package/public/assets/types-BiJFuTiL.js +2 -0
  72. package/public/assets/types-BiJFuTiL.js.map +1 -0
  73. package/public/assets/{user-avatar-CfAUS3PZ.js → user-avatar-BYGrUx1U.js} +2 -2
  74. package/public/assets/{user-avatar-CfAUS3PZ.js.map → user-avatar-BYGrUx1U.js.map} +1 -1
  75. package/public/assets/{work-queue-D-Otxd9u.js → work-queue-BH2-mARc.js} +2 -2
  76. package/public/assets/{work-queue-D-Otxd9u.js.map → work-queue-BH2-mARc.js.map} +1 -1
  77. package/public/assets/{workspaces-CIsQqQby.js → workspaces-DldYyXZR.js} +2 -2
  78. package/public/assets/{workspaces-CIsQqQby.js.map → workspaces-DldYyXZR.js.map} +1 -1
  79. package/public/index.html +4 -4
  80. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  81. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +6 -0
  82. package/src/branch-landed.ts +17 -3
  83. package/src/config-store.ts +12 -1
  84. package/src/db/agents.ts +3 -2
  85. package/src/db/continuations.ts +13 -3
  86. package/src/db/index.ts +3 -0
  87. package/src/db/issue-lifecycle.ts +12 -0
  88. package/src/db/mappers.ts +17 -0
  89. package/src/db/messages.ts +9 -4
  90. package/src/db/migrations.ts +91 -1
  91. package/src/db/notification-subscriptions.ts +1 -1
  92. package/src/db/prompt-templates.ts +19 -0
  93. package/src/db/schema.ts +67 -1
  94. package/src/db/stats.ts +3 -2
  95. package/src/db/task-comment-through.ts +112 -0
  96. package/src/db/task-core.ts +14 -1
  97. package/src/db/task-entity.ts +629 -0
  98. package/src/db/task-maintenance.ts +4 -3
  99. package/src/db/task-memory.ts +180 -0
  100. package/src/db/tasks.ts +24 -10
  101. package/src/issue-tracker/adapter.ts +15 -0
  102. package/src/issue-tracker/github.ts +40 -2
  103. package/src/issue-tracker/index.ts +1 -0
  104. package/src/mcp/index.ts +12 -1
  105. package/src/mcp/tools-spawn.ts +4 -0
  106. package/src/mcp-dispatch-tools.ts +164 -0
  107. package/src/mcp-notification-subscription-tools.ts +62 -4
  108. package/src/mcp-plan-tools.ts +30 -3
  109. package/src/mcp-release-tools.ts +33 -0
  110. package/src/mcp-task-memory-tools.ts +132 -0
  111. package/src/mcp-task-tools.ts +232 -0
  112. package/src/mcp-task-weave-tools.ts +66 -0
  113. package/src/notification-router.ts +24 -2
  114. package/src/notification-rules.ts +4 -0
  115. package/src/notification-types.ts +23 -0
  116. package/src/prompt-resolver.ts +66 -3
  117. package/src/routes/agents-spawn.ts +10 -0
  118. package/src/routes/index.ts +14 -1
  119. package/src/routes/plans.ts +10 -2
  120. package/src/routes/tasks.ts +276 -2
  121. package/src/routing-policy-store.ts +147 -2
  122. package/src/self-resume.ts +13 -2
  123. package/src/services/issue-lifecycle.ts +9 -1
  124. package/src/services/plan-graph.ts +107 -9
  125. package/src/services/release-readiness.ts +142 -0
  126. package/src/services/send-message.ts +31 -0
  127. package/src/services/spawn-agent.ts +81 -4
  128. package/src/services/task-context-hydration.ts +361 -0
  129. package/src/services/task-entity.ts +149 -0
  130. package/src/services/task-issue-weave.ts +558 -0
  131. package/src/services/task-lifecycle.ts +465 -0
  132. package/src/services/task-plan-view.ts +178 -0
  133. package/src/services/task-semantic-events.ts +137 -0
  134. package/src/services/transient-agent-reaper.ts +55 -22
  135. package/src/spawn-targets.ts +27 -2
  136. package/src/task-dispatcher.ts +461 -0
  137. package/src/upgrade-install-verify.ts +20 -1
  138. package/public/assets/display-BWi8DQCp.js +0 -3
  139. package/public/assets/display-BWi8DQCp.js.map +0 -1
  140. package/public/assets/index-3ydeNO0c.js +0 -23
  141. package/public/assets/index-3ydeNO0c.js.map +0 -1
  142. package/public/assets/types-DorDDvEb.js +0 -2
  143. package/public/assets/types-DorDDvEb.js.map +0 -1
@@ -165,7 +165,7 @@ export function applyMigrations(): void {
165
165
  getDb().run(`
166
166
  CREATE TABLE IF NOT EXISTS issue_associations (
167
167
  issue_ref_id TEXT NOT NULL REFERENCES issue_refs(id) ON DELETE CASCADE,
168
- entity_type TEXT NOT NULL CHECK (entity_type IN ('team', 'plan-node', 'epic')),
168
+ entity_type TEXT NOT NULL CHECK (entity_type IN ('team', 'plan-node', 'epic', 'task')),
169
169
  entity_id TEXT NOT NULL,
170
170
  created_at INTEGER NOT NULL,
171
171
  PRIMARY KEY (issue_ref_id, entity_type, entity_id)
@@ -294,6 +294,20 @@ export function applyMigrations(): void {
294
294
  ON continuation_archives(agent_id, generation, created_at DESC)
295
295
  `);
296
296
  ensureContinuationArchiveSearchSchema();
297
+ getDb().run(`
298
+ CREATE TABLE IF NOT EXISTS task_memories (
299
+ task_id INTEGER PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
300
+ objective TEXT NOT NULL,
301
+ generation INTEGER NOT NULL DEFAULT 0,
302
+ ledger TEXT NOT NULL DEFAULT '[]',
303
+ decisions TEXT NOT NULL DEFAULT '[]',
304
+ working_state TEXT NOT NULL DEFAULT '',
305
+ archive_refs TEXT NOT NULL DEFAULT '[]',
306
+ budget TEXT NOT NULL,
307
+ created_at INTEGER NOT NULL,
308
+ updated_at INTEGER NOT NULL
309
+ )
310
+ `);
297
311
 
298
312
  const tokenCols = getDb().query("PRAGMA table_info(tokens)").all() as any[];
299
313
  const tokenColNames = tokenCols.map((c: any) => c.name);
@@ -1013,4 +1027,80 @@ export function applyMigrations(): void {
1013
1027
  getDb().run("CREATE INDEX IF NOT EXISTS idx_scheduled_timers_creator ON scheduled_timers(created_by, enabled, next_run_at)");
1014
1028
  getDb().run("CREATE INDEX IF NOT EXISTS idx_scheduled_timers_correlation ON scheduled_timers(correlation_id)");
1015
1029
 
1030
+ // #834 — grow `tasks` into the durable Task entity. Additive, kind-fenced columns:
1031
+ // existing rows default to kind='claim'/deliverable_stage='none', so every legacy
1032
+ // claim path is byte-for-byte unaffected. The claim-lease machinery filters kind='claim'.
1033
+ const durableTaskCols = (getDb().query("PRAGMA table_info(tasks)").all() as any[]).map((c: any) => c.name);
1034
+ const addTaskCol = (name: string, ddl: string) => {
1035
+ if (!durableTaskCols.includes(name)) getDb().run(`ALTER TABLE tasks ADD COLUMN ${ddl}`);
1036
+ };
1037
+ addTaskCol("kind", "kind TEXT NOT NULL DEFAULT 'claim'");
1038
+ addTaskCol("intent", "intent TEXT");
1039
+ addTaskCol("latest_steer", "latest_steer TEXT");
1040
+ addTaskCol("deliverable_stage", "deliverable_stage TEXT NOT NULL DEFAULT 'none'");
1041
+ addTaskCol("blocked_by", "blocked_by TEXT");
1042
+ addTaskCol("branch", "branch TEXT");
1043
+ addTaskCol("commits", "commits TEXT");
1044
+ addTaskCol("done_when", "done_when TEXT");
1045
+ addTaskCol("team_id", "team_id TEXT");
1046
+ addTaskCol("created_by", "created_by TEXT");
1047
+ addTaskCol("owner_agent_id", "owner_agent_id TEXT");
1048
+ addTaskCol("dispatch", "dispatch TEXT");
1049
+ addTaskCol("reaped_at", "reaped_at INTEGER");
1050
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_tasks_kind ON tasks(kind, status, updated_at)");
1051
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_tasks_branch ON tasks(branch) WHERE branch IS NOT NULL");
1052
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_tasks_team ON tasks(team_id) WHERE team_id IS NOT NULL");
1053
+
1054
+ getDb().run(`
1055
+ CREATE TABLE IF NOT EXISTS task_dependencies (
1056
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1057
+ depends_on_task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1058
+ created_at INTEGER NOT NULL,
1059
+ PRIMARY KEY (task_id, depends_on_task_id)
1060
+ )
1061
+ `);
1062
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_task_dependencies_depended ON task_dependencies(depends_on_task_id)");
1063
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_task_events_task_type ON task_events(task_id, type, id DESC)");
1064
+
1065
+ // #833 — comment-through idempotency outbox (reserve→post→finalize). Existing DBs gain
1066
+ // the table here; the UNIQUE claim is what makes the write-through concurrency/crash-safe.
1067
+ getDb().run(`
1068
+ CREATE TABLE IF NOT EXISTS task_comment_throughs (
1069
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1070
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
1071
+ kind TEXT NOT NULL,
1072
+ idempotency_key TEXT NOT NULL,
1073
+ status TEXT NOT NULL,
1074
+ event_id INTEGER,
1075
+ created_at INTEGER NOT NULL,
1076
+ updated_at INTEGER NOT NULL,
1077
+ UNIQUE (task_id, kind, idempotency_key)
1078
+ )
1079
+ `);
1080
+
1081
+ // #834 — issue_associations.entity_type gained 'task'. The CHECK constraint is baked into
1082
+ // the table SQL, so a TS-only enum change would fail at runtime; rebuild the table (SQLite
1083
+ // can't ALTER a CHECK). Guarded on the absence of 'task' in the stored DDL so it runs once.
1084
+ const issueAssocSql = (getDb().query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'issue_associations'").get() as { sql?: string } | undefined)?.sql;
1085
+ if (issueAssocSql && !issueAssocSql.includes("'task'")) {
1086
+ getDb().transaction(() => {
1087
+ getDb().run("ALTER TABLE issue_associations RENAME TO issue_associations_old");
1088
+ getDb().run(`
1089
+ CREATE TABLE issue_associations (
1090
+ issue_ref_id TEXT NOT NULL REFERENCES issue_refs(id) ON DELETE CASCADE,
1091
+ entity_type TEXT NOT NULL CHECK (entity_type IN ('team', 'plan-node', 'epic', 'task')),
1092
+ entity_id TEXT NOT NULL,
1093
+ created_at INTEGER NOT NULL,
1094
+ PRIMARY KEY (issue_ref_id, entity_type, entity_id)
1095
+ )
1096
+ `);
1097
+ getDb().run(`
1098
+ INSERT INTO issue_associations (issue_ref_id, entity_type, entity_id, created_at)
1099
+ SELECT issue_ref_id, entity_type, entity_id, created_at FROM issue_associations_old
1100
+ `);
1101
+ getDb().run("DROP TABLE issue_associations_old");
1102
+ })();
1103
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_issue_associations_entity ON issue_associations(entity_type, entity_id, created_at DESC)");
1104
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_issue_associations_issue ON issue_associations(issue_ref_id, created_at DESC)");
1105
+ }
1016
1106
  }
@@ -48,7 +48,7 @@ interface NotificationSubscriptionRow {
48
48
  created_at: number;
49
49
  }
50
50
 
51
- const SCOPE_KEYS = ["author", "agentId", "parent", "teamId", "planId", "repoRoot", "workspaceId", "paths"] as const;
51
+ const SCOPE_KEYS = ["author", "agentId", "parent", "teamId", "planId", "taskId", "transition", "landKey", "repoRoot", "workspaceId", "paths"] as const;
52
52
 
53
53
  export function addSubscription(input: {
54
54
  agentId: string;
@@ -67,6 +67,12 @@ export function seedTemplates(templates: SeedTemplate[]): void {
67
67
  slug, category, title, description, template, default_template, variables, updated_at, created_at
68
68
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
69
69
  `);
70
+ const current = getDb().query("SELECT * FROM prompt_templates WHERE slug = ?");
71
+ const update = getDb().query(`
72
+ UPDATE prompt_templates
73
+ SET category = ?, title = ?, description = ?, template = ?, default_template = ?, variables = ?, updated_at = ?
74
+ WHERE slug = ?
75
+ `);
70
76
  const tx = getDb().transaction((items: SeedTemplate[]) => {
71
77
  for (const item of items) {
72
78
  insert.run(
@@ -80,6 +86,19 @@ export function seedTemplates(templates: SeedTemplate[]): void {
80
86
  now,
81
87
  now,
82
88
  );
89
+ const row = current.get(item.slug) as PromptTemplateRow | null;
90
+ if (!row) continue;
91
+ const template = row.template === row.default_template ? item.template : row.template;
92
+ update.run(
93
+ item.category,
94
+ item.title,
95
+ item.description ?? null,
96
+ template,
97
+ item.defaultTemplate,
98
+ JSON.stringify(item.variables),
99
+ now,
100
+ item.slug,
101
+ );
83
102
  }
84
103
  });
85
104
  tx(templates);
package/src/db/schema.ts CHANGED
@@ -395,6 +395,23 @@ export function initDb(path: string = "agent-relay.db"): Database {
395
395
  CREATE INDEX IF NOT EXISTS idx_continuation_archives_agent_generation
396
396
  ON continuation_archives(agent_id, generation, created_at DESC);
397
397
 
398
+ -- #832 — Task-bound memory is the task-local slice of the #815 scoped-knowledge
399
+ -- chain. It mirrors the self-resume continuation envelope shape now; when #815
400
+ -- lands, this table becomes the task/session scope adapter instead of a parallel
401
+ -- memory system.
402
+ CREATE TABLE IF NOT EXISTS task_memories (
403
+ task_id INTEGER PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
404
+ objective TEXT NOT NULL,
405
+ generation INTEGER NOT NULL DEFAULT 0,
406
+ ledger TEXT NOT NULL DEFAULT '[]',
407
+ decisions TEXT NOT NULL DEFAULT '[]',
408
+ working_state TEXT NOT NULL DEFAULT '',
409
+ archive_refs TEXT NOT NULL DEFAULT '[]',
410
+ budget TEXT NOT NULL,
411
+ created_at INTEGER NOT NULL,
412
+ updated_at INTEGER NOT NULL
413
+ );
414
+
398
415
  CREATE TABLE IF NOT EXISTS memories (
399
416
  id TEXT PRIMARY KEY,
400
417
  type TEXT NOT NULL,
@@ -619,13 +636,58 @@ export function initDb(path: string = "agent-relay.db"): Database {
619
636
  metadata TEXT NOT NULL DEFAULT '{}',
620
637
  created_at INTEGER NOT NULL,
621
638
  updated_at INTEGER NOT NULL,
622
- last_seen_at INTEGER NOT NULL
639
+ last_seen_at INTEGER NOT NULL,
640
+ -- #834 durable-Task facet. kind='claim' (default) is the historical claimable
641
+ -- work-item lane; kind='deliverable' is the durable coordination Task. The claim-
642
+ -- lease machinery is fenced to kind='claim' (see claimTaskWhere in task-core.ts).
643
+ kind TEXT NOT NULL DEFAULT 'claim',
644
+ intent TEXT,
645
+ latest_steer TEXT,
646
+ deliverable_stage TEXT NOT NULL DEFAULT 'none',
647
+ blocked_by TEXT,
648
+ branch TEXT,
649
+ commits TEXT,
650
+ done_when TEXT,
651
+ team_id TEXT,
652
+ created_by TEXT,
653
+ owner_agent_id TEXT,
654
+ dispatch TEXT,
655
+ reaped_at INTEGER
623
656
  );
624
657
 
625
658
  CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_source_dedupe ON tasks(source, dedupe_key) WHERE dedupe_key IS NOT NULL AND status NOT IN ('done', 'failed', 'canceled');
626
659
  CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
627
660
  CREATE INDEX IF NOT EXISTS idx_tasks_target ON tasks(target);
628
661
  CREATE INDEX IF NOT EXISTS idx_tasks_updated ON tasks(updated_at);
662
+ -- idx_tasks_kind / idx_tasks_branch / idx_tasks_team reference the #834 columns and are
663
+ -- created in applyMigrations() AFTER the ALTERs — never inline here: an existing pre-#834
664
+ -- tasks table makes CREATE TABLE IF NOT EXISTS a no-op, so an inline index over a column
665
+ -- it doesn't yet have crashes initDb (same hazard as idx_projects_scope_anchor above).
666
+
667
+ -- #834 task→task dependency DAG (the edges the plan DAG collapses into; #835).
668
+ -- Cycle-rejected transactionally on insert (src/db/task-entity.ts).
669
+ CREATE TABLE IF NOT EXISTS task_dependencies (
670
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
671
+ depends_on_task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
672
+ created_at INTEGER NOT NULL,
673
+ PRIMARY KEY (task_id, depends_on_task_id)
674
+ );
675
+ CREATE INDEX IF NOT EXISTS idx_task_dependencies_depended ON task_dependencies(depends_on_task_id);
676
+
677
+ -- #833 comment-through idempotency outbox: reserve→post→finalize so a Task comment
678
+ -- written THROUGH to GitHub is concurrency- and crash-safe (UNIQUE claim before the
679
+ -- adapter append; receipt only after it succeeds). Logic in src/db/task-comment-through.ts.
680
+ CREATE TABLE IF NOT EXISTS task_comment_throughs (
681
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
682
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
683
+ kind TEXT NOT NULL,
684
+ idempotency_key TEXT NOT NULL,
685
+ status TEXT NOT NULL,
686
+ event_id INTEGER,
687
+ created_at INTEGER NOT NULL,
688
+ updated_at INTEGER NOT NULL,
689
+ UNIQUE (task_id, kind, idempotency_key)
690
+ );
629
691
 
630
692
  CREATE TABLE IF NOT EXISTS automations (
631
693
  id TEXT PRIMARY KEY,
@@ -682,6 +744,10 @@ export function initDb(path: string = "agent-relay.db"): Database {
682
744
  created_at INTEGER NOT NULL
683
745
  );
684
746
 
747
+ -- #834 — supports the deterministic latest-per-gate projection (type LIKE 'gate:%')
748
+ -- and append-history reads. The audit ledger stays append-only; this is just the index.
749
+ CREATE INDEX IF NOT EXISTS idx_task_events_task_type ON task_events(task_id, type, id DESC);
750
+
685
751
  CREATE TABLE IF NOT EXISTS task_callback_deliveries (
686
752
  id INTEGER PRIMARY KEY AUTOINCREMENT,
687
753
  task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
package/src/db/stats.ts CHANGED
@@ -18,6 +18,7 @@ import { listAgents } from "./agents.ts";
18
18
  import { listChannels } from "./channels.ts";
19
19
  import { countExpiredUndeliveredMessages } from "./delivery.ts";
20
20
  import { messageTaskClosed } from "./claim-sql.ts";
21
+ import { claimTaskWhere } from "./task-core.ts";
21
22
  import { getDb } from "./connection.ts";
22
23
  import type {
23
24
  AgentCard,
@@ -428,7 +429,7 @@ export function getHealth(now: number = Date.now()): HealthReport {
428
429
  });
429
430
 
430
431
  const expiredTaskClaims = (getDb()
431
- .query("SELECT COUNT(*) as c FROM tasks WHERE claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')")
432
+ .query(`SELECT COUNT(*) as c FROM tasks WHERE ${claimTaskWhere()} AND claimed_by IS NOT NULL AND (claim_expires_at IS NULL OR claim_expires_at <= ?) AND status IN ('claimed', 'in_progress', 'blocked')`)
432
433
  .get(now) as any).c as number;
433
434
  checks.push({
434
435
  name: "expired-task-claims",
@@ -442,7 +443,7 @@ export function getHealth(now: number = Date.now()): HealthReport {
442
443
  SELECT COUNT(*) as c
443
444
  FROM tasks t
444
445
  JOIN agents a ON a.id = t.claimed_by
445
- WHERE t.status IN ('claimed', 'in_progress', 'blocked') AND a.status = 'offline'
446
+ WHERE ${claimTaskWhere("t.")} AND t.status IN ('claimed', 'in_progress', 'blocked') AND a.status = 'offline'
446
447
  `)
447
448
  .get() as any).c as number;
448
449
  checks.push({
@@ -0,0 +1,112 @@
1
+ // #833 — the comment-through idempotency outbox. The Task↔issue weave writes discussion
2
+ // THROUGH to GitHub (the backing store); to keep "Task is a lens, GitHub is the store"
3
+ // honest, a write-through must be reserve→post→finalize, never scan→post→insert:
4
+ // - a UNIQUE(task_id, kind, idempotency_key) claim is taken TRANSACTIONALLY before the
5
+ // awaited GitHub append, so two concurrent same-key requests can't both post.
6
+ // - a receipt (status='posted') is only written AFTER the adapter succeeds, so a failed
7
+ // GitHub post never leaves a local "posted" record that a retry would dedupe against.
8
+ // A caught failure flips the row to 'failed' (reclaimable — a retry re-posts). A reservation
9
+ // abandoned past RESERVATION_TTL_MS (crash mid-flight) is reclaimable too; within the TTL a
10
+ // concurrent attempt sees 'in_flight' and stands down (at-most-once in the ambiguous window).
11
+ import { getDb, ValidationError } from "./connection.ts";
12
+
13
+ // A crashed reservation older than this is treated as abandoned and reclaimable. Inside the
14
+ // window, a same-key attempt stands down rather than risk a duplicate GitHub comment.
15
+ export const RESERVATION_TTL_MS = 120_000;
16
+
17
+ export type TaskCommentThroughKind = "discussion" | "milestone";
18
+ export type TaskCommentThroughStatus = "reserved" | "posted" | "failed";
19
+
20
+ export interface TaskCommentThroughRecord {
21
+ id: number;
22
+ taskId: number;
23
+ kind: TaskCommentThroughKind;
24
+ idempotencyKey: string;
25
+ status: TaskCommentThroughStatus;
26
+ eventId?: number;
27
+ createdAt: number;
28
+ updatedAt: number;
29
+ }
30
+
31
+ interface TaskCommentThroughRow {
32
+ id: number;
33
+ task_id: number;
34
+ kind: TaskCommentThroughKind;
35
+ idempotency_key: string;
36
+ status: TaskCommentThroughStatus;
37
+ event_id: number | null;
38
+ created_at: number;
39
+ updated_at: number;
40
+ }
41
+
42
+ function rowTo(row: TaskCommentThroughRow): TaskCommentThroughRecord {
43
+ return {
44
+ id: row.id,
45
+ taskId: row.task_id,
46
+ kind: row.kind,
47
+ idempotencyKey: row.idempotency_key,
48
+ status: row.status,
49
+ ...(row.event_id !== null ? { eventId: row.event_id } : {}),
50
+ createdAt: row.created_at,
51
+ updatedAt: row.updated_at,
52
+ };
53
+ }
54
+
55
+ function getRow(taskId: number, kind: TaskCommentThroughKind, key: string): TaskCommentThroughRow | undefined {
56
+ return getDb()
57
+ .query("SELECT * FROM task_comment_throughs WHERE task_id = ? AND kind = ? AND idempotency_key = ?")
58
+ .get(taskId, kind, key) as TaskCommentThroughRow | undefined;
59
+ }
60
+
61
+ // reserved → caller holds the claim; proceed to post, then finalize/fail. `reconcileNeeded`
62
+ // is true ONLY when a stale `reserved` row was reclaimed — that case is ambiguous
63
+ // (the prior attempt may have posted to GitHub then crashed before finalize), so
64
+ // the caller MUST reconcile against the issue's comments before re-posting. A
65
+ // `failed` reclaim or a fresh reservation is unambiguous (reconcileNeeded=false).
66
+ // in_flight → another attempt holds a fresh claim; caller must NOT post (dedupe).
67
+ // duplicate → already posted; caller returns the prior receipt.
68
+ export type ReserveOutcome =
69
+ | { outcome: "reserved"; record: TaskCommentThroughRecord; reconcileNeeded: boolean }
70
+ | { outcome: "in_flight"; record: TaskCommentThroughRecord }
71
+ | { outcome: "duplicate"; record: TaskCommentThroughRecord };
72
+
73
+ // Transactional claim. Runs check+insert/update inside a single SQLite write transaction
74
+ // (single-writer ⇒ serialized), so concurrent callers can never both come back 'reserved'.
75
+ export function reserveCommentThrough(
76
+ taskId: number,
77
+ kind: TaskCommentThroughKind,
78
+ idempotencyKey: string,
79
+ now: number,
80
+ ): ReserveOutcome {
81
+ const key = idempotencyKey.trim();
82
+ if (!key) throw new ValidationError("idempotencyKey required to reserve a comment-through");
83
+ return getDb().transaction((): ReserveOutcome => {
84
+ const existing = getRow(taskId, kind, key);
85
+ if (existing) {
86
+ if (existing.status === "posted") return { outcome: "duplicate", record: rowTo(existing) };
87
+ const staleReserved = existing.status === "reserved" && now - existing.updated_at >= RESERVATION_TTL_MS;
88
+ const reclaimable = existing.status === "failed" || staleReserved;
89
+ if (!reclaimable) return { outcome: "in_flight", record: rowTo(existing) };
90
+ getDb().query("UPDATE task_comment_throughs SET status = 'reserved', event_id = NULL, updated_at = ? WHERE id = ?").run(now, existing.id);
91
+ // Only a stale-`reserved` reclaim is ambiguous about whether GitHub already got the post.
92
+ return { outcome: "reserved", record: rowTo(getRow(taskId, kind, key)!), reconcileNeeded: staleReserved };
93
+ }
94
+ getDb()
95
+ .query("INSERT INTO task_comment_throughs (task_id, kind, idempotency_key, status, created_at, updated_at) VALUES (?, ?, ?, 'reserved', ?, ?)")
96
+ .run(taskId, kind, key, now, now);
97
+ return { outcome: "reserved", record: rowTo(getRow(taskId, kind, key)!), reconcileNeeded: false };
98
+ })();
99
+ }
100
+
101
+ export function finalizeCommentThrough(id: number, eventId: number, now: number): void {
102
+ getDb().query("UPDATE task_comment_throughs SET status = 'posted', event_id = ?, updated_at = ? WHERE id = ?").run(eventId, now, id);
103
+ }
104
+
105
+ export function failCommentThrough(id: number, now: number): void {
106
+ getDb().query("UPDATE task_comment_throughs SET status = 'failed', event_id = NULL, updated_at = ? WHERE id = ?").run(now, id);
107
+ }
108
+
109
+ export function getCommentThrough(taskId: number, kind: TaskCommentThroughKind, key: string): TaskCommentThroughRecord | null {
110
+ const row = getRow(taskId, kind, key.trim());
111
+ return row ? rowTo(row) : null;
112
+ }
@@ -4,6 +4,19 @@ import type { TaskEvent } from "../types";
4
4
 
5
5
  export const TASK_SELECT = "SELECT * FROM tasks";
6
6
 
7
+ // #834 — the claim/deliverable partition is a REAL fence, not a convention. Every claim,
8
+ // lease, orphan, on-demand-settle and health query that touches `claimed_by` MUST be scoped
9
+ // to kind='claim' so the claim-lease machinery can never mutate a durable deliverable Task
10
+ // (e.g. release/orphan one whose owner_agent_id is set). Route ALL such SQL through this.
11
+ export const CLAIM_TASK_KIND = "claim";
12
+ export const DELIVERABLE_TASK_KIND = "deliverable";
13
+
14
+ /** SQL predicate fencing a query to claim-lane tasks. `prefix` is an optional table
15
+ * alias with trailing dot (e.g. `claimTaskWhere("t.")` → `t.kind = 'claim'`). */
16
+ export function claimTaskWhere(prefix = ""): string {
17
+ return `${prefix}kind = '${CLAIM_TASK_KIND}'`;
18
+ }
19
+
7
20
  export function insertTaskEvent(taskId: number, event: Required<Omit<TaskEvent, "id" | "taskId" | "createdAt">>, now: number): TaskEvent {
8
21
  const result = getDb().query(`
9
22
  INSERT INTO task_events (task_id, source, type, severity, title, body, metadata, created_at)
@@ -12,7 +25,7 @@ export function insertTaskEvent(taskId: number, event: Required<Omit<TaskEvent,
12
25
  return getTaskEvent(Number(result.lastInsertRowid))!;
13
26
  }
14
27
 
15
- function getTaskEvent(id: number): TaskEvent | null {
28
+ export function getTaskEvent(id: number): TaskEvent | null {
16
29
  const row = getDb().query("SELECT * FROM task_events WHERE id = ?").get(id) as any;
17
30
  return row ? rowToTaskEvent(row) : null;
18
31
  }