melchizedek-agents 1.0.3 → 1.2.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 (172) hide show
  1. package/.env.example +29 -2
  2. package/AGENT_SETUP.md +3 -1
  3. package/CHANGELOG.md +524 -0
  4. package/DOCUMENTATION.md +178 -43
  5. package/ONBOARDING.md +339 -0
  6. package/QUICKSTART.md +15 -2
  7. package/README.md +6 -2
  8. package/config/agents/README.md +2 -2
  9. package/config/agents/examples/ares.yaml +6 -10
  10. package/config/agents/examples/assistant.yaml +6 -6
  11. package/config/agents/examples/augustin.yaml +9 -15
  12. package/config/agents/examples/cartographers.yaml +6 -6
  13. package/config/agents/examples/council.yaml +7 -7
  14. package/config/agents/examples/critic.yaml +24 -24
  15. package/config/agents/examples/harness.yaml +4 -4
  16. package/config/agents/examples/image_production.yaml +37 -35
  17. package/config/agents/examples/librarian.yaml +4 -4
  18. package/config/agents/examples/patient_advocate.yaml +6 -10
  19. package/config/agents/examples/pipeline.yaml +20 -19
  20. package/config/agents/examples/research.yaml +36 -43
  21. package/config/agents/examples/scribe.yaml +31 -32
  22. package/config/agents/examples/scriptorium.yaml +6 -6
  23. package/config/agents/examples/structured_critic.yaml +82 -0
  24. package/config/agents/examples/style_council.yaml +9 -11
  25. package/config/agents/examples/syndicate.yaml +3 -5
  26. package/config/agents/examples/tutor.yaml +2 -2
  27. package/config/agents/examples/weather.yaml +2 -2
  28. package/config/agents/syndicate.schema.json +859 -13
  29. package/config/agents/syndicateSchema.yaml +122 -73
  30. package/config/agents/templates/README.md +1 -1
  31. package/config/agents/templates/account_memory.yaml +4 -4
  32. package/config/agents/templates/case_desk.yaml +6 -6
  33. package/config/agents/templates/conversational.yaml +2 -2
  34. package/config/agents/templates/draft_review.yaml +6 -6
  35. package/config/agents/templates/intake_extractor.yaml +64 -62
  36. package/config/agents/templates/research_brief.yaml +6 -6
  37. package/config/agents/templates/research_desk.yaml +6 -6
  38. package/config/agents/templates/review_panel.yaml +8 -8
  39. package/config/agents/templates/support_triage.yaml +30 -29
  40. package/config/agents/templates/systems_operator.yaml +24 -5
  41. package/db/migrations/0014_durable_runs.sql +31 -0
  42. package/dist/lib/a2a/app.d.ts +27 -0
  43. package/dist/lib/a2a/app.js +64 -5
  44. package/dist/lib/a2a/executor.d.ts +7 -1
  45. package/dist/lib/a2a/executor.js +43 -5
  46. package/dist/lib/a2a/oauthSetup.d.ts +106 -0
  47. package/dist/lib/a2a/oauthSetup.js +259 -0
  48. package/dist/lib/agentDialect.d.ts +76 -0
  49. package/dist/lib/agentDialect.js +151 -0
  50. package/dist/lib/compile.d.ts +42 -6
  51. package/dist/lib/compile.js +97 -45
  52. package/dist/lib/compileNative.d.ts +11 -3
  53. package/dist/lib/compileNative.js +20 -1
  54. package/dist/lib/dispatch.d.ts +10 -5
  55. package/dist/lib/dispatch.js +10 -5
  56. package/dist/lib/doctor.d.ts +110 -1
  57. package/dist/lib/doctor.js +205 -9
  58. package/dist/lib/loadSyndicate.d.ts +54 -2
  59. package/dist/lib/loadSyndicate.js +22 -2
  60. package/dist/lib/memory/namespace.d.ts +6 -5
  61. package/dist/lib/memory/namespace.js +6 -5
  62. package/dist/lib/memory/providers.js +3 -4
  63. package/dist/lib/models/capabilities.d.ts +25 -1
  64. package/dist/lib/models/capabilities.js +79 -1
  65. package/dist/lib/models/chatCompletionsAdapter.d.ts +11 -3
  66. package/dist/lib/models/chatCompletionsAdapter.js +19 -5
  67. package/dist/lib/models/claudeAdapter.js +4 -0
  68. package/dist/lib/models/contract.d.ts +5 -2
  69. package/dist/lib/models/gateway.d.ts +2 -1
  70. package/dist/lib/models/gateway.js +4 -2
  71. package/dist/lib/models/gatewayAdapter.d.ts +7 -1
  72. package/dist/lib/models/gatewayAdapter.js +8 -0
  73. package/dist/lib/models/geminiAdapter.js +8 -2
  74. package/dist/lib/models/genaiMapping.d.ts +8 -4
  75. package/dist/lib/models/genaiMapping.js +23 -9
  76. package/dist/lib/models/gptAdapter.d.ts +4 -2
  77. package/dist/lib/models/gptAdapter.js +11 -4
  78. package/dist/lib/models/grokAdapter.d.ts +3 -1
  79. package/dist/lib/models/grokAdapter.js +11 -3
  80. package/dist/lib/models/kimiAdapter.d.ts +2 -1
  81. package/dist/lib/models/kimiAdapter.js +3 -3
  82. package/dist/lib/models/reasoning.d.ts +30 -2
  83. package/dist/lib/models/reasoning.js +56 -5
  84. package/dist/lib/observability/tracer.d.ts +8 -1
  85. package/dist/lib/observability/tracer.js +33 -5
  86. package/dist/lib/onboarding.d.ts +134 -0
  87. package/dist/lib/onboarding.js +539 -0
  88. package/dist/lib/runtime/approvals.d.ts +8 -0
  89. package/dist/lib/runtime/approvals.js +4 -3
  90. package/dist/lib/runtime/credentials.d.ts +6 -0
  91. package/dist/lib/runtime/native/agentLoop.d.ts +23 -3
  92. package/dist/lib/runtime/native/agentLoop.js +107 -11
  93. package/dist/lib/runtime/native/checkpoint.d.ts +171 -0
  94. package/dist/lib/runtime/native/checkpoint.js +359 -0
  95. package/dist/lib/runtime/native/delegate.d.ts +222 -18
  96. package/dist/lib/runtime/native/delegate.js +390 -39
  97. package/dist/lib/runtime/native/interrupts.d.ts +184 -2
  98. package/dist/lib/runtime/native/interrupts.js +391 -1
  99. package/dist/lib/runtime/native/request.d.ts +13 -3
  100. package/dist/lib/runtime/native/request.js +28 -4
  101. package/dist/lib/runtime/native/telemetry.js +10 -15
  102. package/dist/lib/runtime/questions.d.ts +7 -4
  103. package/dist/lib/runtime/sessions.d.ts +1 -1
  104. package/dist/lib/runtime/syndicateTurn.d.ts +11 -3
  105. package/dist/lib/runtime/syndicateTurn.js +353 -68
  106. package/dist/lib/runtime/turnControl.d.ts +56 -0
  107. package/dist/lib/runtime/turnControl.js +10 -0
  108. package/dist/lib/session/transcript.d.ts +9 -0
  109. package/dist/lib/session/transcript.js +20 -1
  110. package/dist/lib/skills.d.ts +18 -0
  111. package/dist/lib/skills.js +73 -1
  112. package/dist/lib/storage/postgres/index.d.ts +12 -2
  113. package/dist/lib/storage/postgres/index.js +8 -3
  114. package/dist/lib/storage/postgres/taskQueue.d.ts +20 -4
  115. package/dist/lib/storage/postgres/taskQueue.js +29 -5
  116. package/dist/lib/storage/postgres/taskStore.d.ts +26 -2
  117. package/dist/lib/storage/postgres/taskStore.js +74 -8
  118. package/dist/lib/syndicateSchema.d.ts +316 -1
  119. package/dist/lib/syndicateSchema.js +203 -29
  120. package/dist/lib/tools/auth.d.ts +20 -6
  121. package/dist/lib/tools/auth.js +21 -5
  122. package/dist/lib/tools/credentialEnv.d.ts +28 -0
  123. package/dist/lib/tools/credentialEnv.js +52 -0
  124. package/dist/lib/tools/credentialHosts.d.ts +88 -0
  125. package/dist/lib/tools/credentialHosts.js +165 -0
  126. package/dist/lib/tools/credentialStore.js +9 -2
  127. package/dist/lib/tools/credentialUses.d.ts +92 -0
  128. package/dist/lib/tools/credentialUses.js +112 -0
  129. package/dist/lib/tools/mcpToolFactory.d.ts +25 -2
  130. package/dist/lib/tools/mcpToolFactory.js +179 -21
  131. package/dist/lib/tools/oauthConsent.js +8 -1
  132. package/dist/lib/tools/oauthHosts.d.ts +73 -0
  133. package/dist/lib/tools/oauthHosts.js +154 -0
  134. package/dist/lib/tools/oauthTools.d.ts +242 -0
  135. package/dist/lib/tools/oauthTools.js +447 -0
  136. package/dist/lib/tools/openapiTools.d.ts +11 -6
  137. package/dist/lib/tools/openapiTools.js +88 -38
  138. package/dist/lib/tools/taskTools.d.ts +61 -6
  139. package/dist/lib/tools/taskTools.js +145 -21
  140. package/dist/lib/workflow/turn.d.ts +22 -3
  141. package/dist/lib/workflow/turn.js +151 -31
  142. package/dist/lib/workflow.d.ts +14 -7
  143. package/dist/lib/workflow.js +14 -7
  144. package/dist/lib/workflowConfig.d.ts +6 -0
  145. package/dist/scripts/a2a_server.d.ts +36 -0
  146. package/dist/scripts/a2a_server.js +131 -1
  147. package/dist/scripts/assistant_worker.d.ts +12 -0
  148. package/dist/scripts/assistant_worker.js +81 -22
  149. package/dist/scripts/doctor.d.ts +7 -2
  150. package/dist/scripts/doctor.js +9 -4
  151. package/dist/scripts/setup.d.ts +23 -0
  152. package/dist/scripts/setup.js +154 -0
  153. package/dist/scripts/skills_install.d.ts +3 -1
  154. package/dist/scripts/skills_install.js +17 -2
  155. package/dist/scripts/yaml_codemod.d.ts +62 -0
  156. package/dist/scripts/yaml_codemod.js +652 -0
  157. package/package.json +8 -2
  158. package/skills/README.md +11 -2
  159. package/skills/briefs/_shared.md +1 -1
  160. package/skills/briefs/melchizedek-author.md +7 -5
  161. package/skills/briefs/melchizedek-models.md +7 -4
  162. package/skills/briefs/melchizedek.md +1 -0
  163. package/skills/briefs/skills-readme.md +3 -1
  164. package/skills/melchizedek/SKILL.md +1 -0
  165. package/skills/melchizedek-author/SKILL.md +7 -4
  166. package/skills/melchizedek-author/assets/minimal.yaml +13 -13
  167. package/skills/melchizedek-models/SKILL.md +11 -4
  168. package/skills/melchizedek-onboard/SKILL.md +49 -0
  169. package/skills/melchizedek-onboard-cloud/SKILL.md +38 -0
  170. package/skills/melchizedek-onboard-keys/SKILL.md +35 -0
  171. package/skills/melchizedek-onboard-local/SKILL.md +41 -0
  172. package/skills/melchizedek-onboard-serve/SKILL.md +43 -0
@@ -35,10 +35,57 @@ export interface TurnControl {
35
35
  thinkingTokens: number;
36
36
  /** Ceiling on model calls for the whole turn; undefined = no ceiling. */
37
37
  readonly maxLlmCalls?: number;
38
+ /**
39
+ * The turn opted out of tracing (`runSyndicateTurn({ trace: false })`):
40
+ * nothing it runs opens a span or starts the tracer, so it reaches no
41
+ * sink. The budget and the token charge still apply.
42
+ */
43
+ readonly untraced?: boolean;
38
44
  /** Set once, by whichever control stopped the turn first. */
39
45
  stopReason?: TurnStopReason;
40
46
  /** Stop the turn. The first reason wins. */
41
47
  stop(reason: TurnStopReason): void;
48
+ /**
49
+ * Runs a nested dispatch syndicate as its own turn on its own
50
+ * conversation, under this turn's controls (ADR 0120): set by the turn
51
+ * runner (lib/runtime/syndicateTurn.ts), read where a nested dispatch
52
+ * syndicate runs (a delegated call, a dispatch route, a workflow node),
53
+ * which sit below the turn runner and cannot import it.
54
+ */
55
+ nestedDispatch?: (run: NestedDispatchRun) => Promise<NestedDispatchEnd>;
56
+ }
57
+ /** One run of a nested dispatch syndicate (ADR 0120): what it is, where its conversation is filed, and the message. */
58
+ export interface NestedDispatchRun {
59
+ /** The nested syndicate's config (a SyndicateYamlConfig with a `dispatch:` block). */
60
+ config: unknown;
61
+ /** The compile options it loads its routes with (CompileOptions, its nesting chain included). */
62
+ compile: unknown;
63
+ /** The entry's name: the nested syndicate as its caller lists it. */
64
+ name: string;
65
+ /** The store, and the key its conversation is filed under (already opened by the caller). */
66
+ sessions: unknown;
67
+ appName: string;
68
+ userId: string;
69
+ sessionId: string;
70
+ /** The message's parts: the request, or the answer to what waits in it. */
71
+ parts: unknown[];
72
+ }
73
+ /** How a nested dispatch syndicate's turn ended (ADR 0120). */
74
+ export interface NestedDispatchEnd {
75
+ status: 'completed' | 'input-required' | 'failed' | 'canceled';
76
+ /** The route's final text: the nested syndicate's answer. */
77
+ text: string;
78
+ /** Its conversation's state writes this run, `temp:` keys aside. */
79
+ stateDelta: Record<string, unknown>;
80
+ /** What it waits on, when paused: the request's id, and the request as the turn reports it (a PendingApproval, a PendingInput, a PendingConsent). */
81
+ interruptId?: string;
82
+ approval?: unknown;
83
+ input?: unknown;
84
+ consent?: unknown;
85
+ error?: {
86
+ code: string;
87
+ message: string;
88
+ };
42
89
  }
43
90
  export interface TurnControlOptions {
44
91
  maxLlmCalls?: number;
@@ -46,6 +93,8 @@ export interface TurnControlOptions {
46
93
  deadlineMs?: number;
47
94
  /** An outer signal (a cancel request); aborting it cancels the turn. */
48
95
  signal?: AbortSignal;
96
+ /** The turn records no spans (TurnControl.untraced). */
97
+ untraced?: boolean;
49
98
  }
50
99
  /**
51
100
  * A control for one turn. Call `dispose()` when the turn ends so the
@@ -60,6 +109,13 @@ export declare function stopMessage(reason: TurnStopReason, control?: Pick<TurnC
60
109
  export declare function runWithTurnControl<T>(control: TurnControl, fn: () => T): T;
61
110
  /** The current turn's control, if this code is running inside a turn. */
62
111
  export declare function currentTurnControl(): TurnControl | undefined;
112
+ /**
113
+ * Whether the code running now belongs to a turn that opted out of tracing.
114
+ * Every span the engine opens checks this first (lib/observability/
115
+ * tracer.ts, lib/runtime/native/telemetry.ts): an untraced turn opens no
116
+ * span and does not start the tracer. Outside a turn: false.
117
+ */
118
+ export declare function turnUntraced(): boolean;
63
119
  /** The current turn's abort signal, for adapters to pass to provider SDKs. */
64
120
  export declare function currentTurnSignal(): AbortSignal | undefined;
65
121
  /**
@@ -38,6 +38,7 @@ export function createTurnControl(opts = {}) {
38
38
  outputTokens: 0,
39
39
  thinkingTokens: 0,
40
40
  maxLlmCalls: opts.maxLlmCalls && opts.maxLlmCalls > 0 ? opts.maxLlmCalls : undefined,
41
+ ...(opts.untraced ? { untraced: true } : {}),
41
42
  stopReason: undefined,
42
43
  stop(reason) {
43
44
  if (control.stopReason)
@@ -82,6 +83,15 @@ export function runWithTurnControl(control, fn) {
82
83
  export function currentTurnControl() {
83
84
  return storage.getStore();
84
85
  }
86
+ /**
87
+ * Whether the code running now belongs to a turn that opted out of tracing.
88
+ * Every span the engine opens checks this first (lib/observability/
89
+ * tracer.ts, lib/runtime/native/telemetry.ts): an untraced turn opens no
90
+ * span and does not start the tracer. Outside a turn: false.
91
+ */
92
+ export function turnUntraced() {
93
+ return storage.getStore()?.untraced === true;
94
+ }
85
95
  /** The current turn's abort signal, for adapters to pass to provider SDKs. */
86
96
  export function currentTurnSignal() {
87
97
  return storage.getStore()?.signal;
@@ -232,6 +232,15 @@ export declare class ProjectedSessionService implements SessionService {
232
232
  * The event lands in the projected session the runtime holds and, through
233
233
  * the store, in the real one `get` read. A session `get` did not hand out
234
234
  * is the real one, and the store alone applies the event to it.
235
+ *
236
+ * A store may answer an append with events of its own: a durable run's
237
+ * store restoring a checkpoint (lib/runtime/native/checkpoint.ts, ADR 0113)
238
+ * answers the opening message with the run's stored events, the stored
239
+ * opening returned in place of the one given. The projected session then
240
+ * takes, in place of the event given, every event the real session gained,
241
+ * unprojected: they are the current turn, which is never projected. The
242
+ * agent loop thereby reads the restored steps, as it would on the store's
243
+ * own session.
235
244
  */
236
245
  append(session: EngineSession, event: TurnEvent): Promise<TurnEvent>;
237
246
  }
@@ -340,6 +340,15 @@ export class ProjectedSessionService {
340
340
  * The event lands in the projected session the runtime holds and, through
341
341
  * the store, in the real one `get` read. A session `get` did not hand out
342
342
  * is the real one, and the store alone applies the event to it.
343
+ *
344
+ * A store may answer an append with events of its own: a durable run's
345
+ * store restoring a checkpoint (lib/runtime/native/checkpoint.ts, ADR 0113)
346
+ * answers the opening message with the run's stored events, the stored
347
+ * opening returned in place of the one given. The projected session then
348
+ * takes, in place of the event given, every event the real session gained,
349
+ * unprojected: they are the current turn, which is never projected. The
350
+ * agent loop thereby reads the restored steps, as it would on the store's
351
+ * own session.
343
352
  */
344
353
  async append(session, event) {
345
354
  if (event.partial)
@@ -347,7 +356,17 @@ export class ProjectedSessionService {
347
356
  const real = this.stored.get(this.key(session.appName, session.userId, session.id));
348
357
  if (!real || real === session)
349
358
  return this.own.append(session, event);
359
+ const before = real.events.length;
360
+ const at = session.events.length;
350
361
  applyEvent(session, event);
351
- return this.own.append(real, event);
362
+ const stored = await this.own.append(real, event);
363
+ if (stored.id !== event.id) {
364
+ // The store answered with its own events: they replace the one applied above.
365
+ if (session.events[at]?.id === event.id)
366
+ session.events.splice(at, 1);
367
+ for (const gained of real.events.slice(before))
368
+ applyEvent(session, gained);
369
+ }
370
+ return stored;
352
371
  }
353
372
  }
@@ -48,3 +48,21 @@ export interface InstallResult {
48
48
  export declare function destinationsFor(opts: InstallOptions): string[];
49
49
  /** Copy the suite into every destination. Returns one result per destination. */
50
50
  export declare function installSkills(opts?: InstallOptions): InstallResult[];
51
+ /** The markers that delimit the block this installer owns inside AGENTS.md. */
52
+ export declare const AGENTS_MD_BEGIN = "<!-- melchizedek-skills:begin -->";
53
+ export declare const AGENTS_MD_END = "<!-- melchizedek-skills:end -->";
54
+ /** The pointer: where the skills are, and the onboarding entry point. */
55
+ export declare function agentsMdBlock(destinations: string[], projectRoot: string): string;
56
+ export interface AgentsMdResult {
57
+ path: string;
58
+ status: 'created' | 'appended' | 'updated' | 'unchanged';
59
+ }
60
+ /**
61
+ * Write the pointer into `<projectRoot>/AGENTS.md`, the instructions file
62
+ * Codex and other agents read: create the file when absent, append the block
63
+ * when the file has none, or replace only the block between the markers.
64
+ * Nothing outside the markers is ever changed.
65
+ */
66
+ export declare function writeAgentsMdPointer(projectRoot: string, destinations: string[], opts?: {
67
+ dryRun?: boolean;
68
+ }): AgentsMdResult;
@@ -15,7 +15,7 @@
15
15
  * A SKILL.md's frontmatter is read by the skills harness's own parser
16
16
  * (lib/tools/skills/frontmatter.ts), the one an agent's `skills:` uses.
17
17
  */
18
- import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
18
+ import { closeSync, constants as fsConstants, existsSync, fstatSync, ftruncateSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, readFileSync, statSync, writeFileSync, writeSync } from 'node:fs';
19
19
  import { homedir } from 'node:os';
20
20
  import { dirname, join, relative, resolve } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
@@ -162,3 +162,75 @@ export function installSkills(opts = {}) {
162
162
  }
163
163
  return results;
164
164
  }
165
+ // ── The AGENTS.md pointer ────────────────────────────────────────────────────
166
+ /** The markers that delimit the block this installer owns inside AGENTS.md. */
167
+ export const AGENTS_MD_BEGIN = '<!-- melchizedek-skills:begin -->';
168
+ export const AGENTS_MD_END = '<!-- melchizedek-skills:end -->';
169
+ /** The pointer: where the skills are, and the onboarding entry point. */
170
+ export function agentsMdBlock(destinations, projectRoot) {
171
+ const where = destinations.map((d) => `\`${relative(projectRoot, d) || '.'}/\``).join(' and ');
172
+ return [
173
+ AGENTS_MD_BEGIN,
174
+ '## Melchizedek agent skills',
175
+ '',
176
+ `The melchizedek-agents skills are installed in ${where}, one directory per skill with a SKILL.md.`,
177
+ 'To onboard someone, start with `melchizedek-onboard/SKILL.md`, or run `npx melchizedek-setup --auto`.',
178
+ 'Never ask for an API key in chat and never print a value from `.env`: the person types keys into `.env` themselves.',
179
+ AGENTS_MD_END,
180
+ ].join('\n');
181
+ }
182
+ /**
183
+ * Write the pointer into `<projectRoot>/AGENTS.md`, the instructions file
184
+ * Codex and other agents read: create the file when absent, append the block
185
+ * when the file has none, or replace only the block between the markers.
186
+ * Nothing outside the markers is ever changed.
187
+ */
188
+ export function writeAgentsMdPointer(projectRoot, destinations, opts = {}) {
189
+ const path = join(resolve(projectRoot), 'AGENTS.md');
190
+ const block = agentsMdBlock(destinations, resolve(projectRoot));
191
+ const plan = (text) => {
192
+ if (text === undefined)
193
+ return { status: 'created', next: `# AGENTS.md\n\n${block}\n` };
194
+ const start = text.indexOf(AGENTS_MD_BEGIN);
195
+ const end = text.indexOf(AGENTS_MD_END);
196
+ if (start !== -1 && end > start) {
197
+ const next = text.slice(0, start) + block + text.slice(end + AGENTS_MD_END.length);
198
+ return { status: next === text ? 'unchanged' : 'updated', next };
199
+ }
200
+ return { status: 'appended', next: `${text}${text.endsWith('\n') ? '' : '\n'}\n${block}\n` };
201
+ };
202
+ if (opts.dryRun) {
203
+ // Nothing is written, so a path check is enough to report what would happen.
204
+ if (existsSync(path) && lstatSync(path).isSymbolicLink())
205
+ throw new Error(`refusing to write through a symlink: ${path}`);
206
+ return { path, status: plan(existsSync(path) ? readFileSync(path, 'utf-8') : undefined).status };
207
+ }
208
+ // One handle for the read and the write, so the file read is the file written:
209
+ // created if absent, never followed through a symlink (O_NOFOLLOW).
210
+ let fd;
211
+ try {
212
+ fd = openSync(path, fsConstants.O_RDWR | fsConstants.O_CREAT | (fsConstants.O_NOFOLLOW ?? 0), 0o644);
213
+ }
214
+ catch (err) {
215
+ if (err.code === 'ELOOP')
216
+ throw new Error(`refusing to write through a symlink: ${path}`);
217
+ throw err;
218
+ }
219
+ let status;
220
+ try {
221
+ const stat = fstatSync(fd);
222
+ if (!stat.isFile())
223
+ throw new Error(`not a regular file: ${path}`);
224
+ const existing = stat.size > 0 ? readFileSync(fd, 'utf-8') : undefined;
225
+ const result = plan(existing);
226
+ status = result.status;
227
+ if (status !== 'unchanged') {
228
+ ftruncateSync(fd, 0);
229
+ writeSync(fd, result.next, 0);
230
+ }
231
+ }
232
+ finally {
233
+ closeSync(fd);
234
+ }
235
+ return { path, status };
236
+ }
@@ -21,7 +21,7 @@ import type { CredentialStoreOptions } from '../../tools/credentialStore.ts';
21
21
  import type { CredentialStore } from '../../tools/auth.ts';
22
22
  import type { AuditSink } from '../../observability/audit.ts';
23
23
  import type { RlsHardeningStatus } from '../rlsStatus.ts';
24
- import type { TaskBackend } from '../../tools/taskTools.ts';
24
+ import type { TaskBackend, TaskBackendOptions } from '../../tools/taskTools.ts';
25
25
  import type { TurnLock } from '../../a2a/turnLock.ts';
26
26
  import type { PostgresTaskStoreOptions } from './taskStore.ts';
27
27
  export { PostgresSessionService } from './sessionService.ts';
@@ -73,6 +73,11 @@ export interface PostgresStorageOptions {
73
73
  * go to this storage's audit trail.
74
74
  */
75
75
  credentials?: Omit<CredentialStoreOptions, 'rows' | 'audit'>;
76
+ /**
77
+ * The job queue's checkpoints (ADR 0113): the largest one stored, in bytes
78
+ * of JSON (default 5 MiB), and where a skipped save is logged.
79
+ */
80
+ taskQueue?: TaskBackendOptions;
76
81
  }
77
82
  export interface PostgresStorage {
78
83
  pool: Pool;
@@ -99,12 +104,17 @@ export interface PostgresStorage {
99
104
  turnLock: TurnLock;
100
105
  /** The task tools' lists and job queue (lib/tools/taskTools.ts setTaskBackend). */
101
106
  taskQueue: TaskBackend;
102
- /** Task leases: renew this instance's, fail other instances' expired ones. */
107
+ /**
108
+ * Task leases: renew this instance's, fail other instances' expired ones,
109
+ * and list this instance's running tasks a caller asked another instance
110
+ * to cancel (migration 0014, ADR 0113).
111
+ */
103
112
  leases: {
104
113
  instanceId: string;
105
114
  ttlMs: number;
106
115
  renew: () => Promise<number>;
107
116
  reap: () => Promise<number>;
117
+ cancelRequested?: () => Promise<string[]>;
108
118
  };
109
119
  /** Closes the pool when this module created it, and the lock pool. */
110
120
  close: () => Promise<void>;
@@ -16,7 +16,7 @@ import { SupabaseVectorMemoryService } from '../../memory/supabaseMemoryService.
16
16
  import { ERASE_STORES } from '../../memory/erase.js';
17
17
  import { postgresMemoryStore } from './memoryStore.js';
18
18
  import { PostgresSessionService } from './sessionService.js';
19
- import { PostgresTaskStore, reapExpiredTasks, renewTaskLeases } from './taskStore.js';
19
+ import { PostgresTaskStore, cancelRequestedTasks, reapExpiredTasks, renewTaskLeases } from './taskStore.js';
20
20
  import { postgresTaskBackend } from './taskQueue.js';
21
21
  import { postgresCredentialStore } from './credentialStore.js';
22
22
  import { searchPathOption } from '../schema.js';
@@ -114,8 +114,13 @@ export function postgresStorage(options) {
114
114
  ...(credentials ? { credentials } : {}),
115
115
  ...(memoryService ? { memoryService } : {}),
116
116
  taskStore: (agentId) => new PostgresTaskStore(pool, agentId, { ttlDays: options.ttlDays, ownerResolver: options.taskOwner, lease }),
117
- leases: { ...lease, renew: () => renewTaskLeases(pool, lease), reap: () => reapExpiredTasks(pool) },
118
- taskQueue: postgresTaskBackend(pool),
117
+ leases: {
118
+ ...lease,
119
+ renew: () => renewTaskLeases(pool, lease),
120
+ reap: () => reapExpiredTasks(pool),
121
+ cancelRequested: () => cancelRequestedTasks(pool, lease),
122
+ },
123
+ taskQueue: postgresTaskBackend(pool, options.taskQueue),
119
124
  async schemaVersion() {
120
125
  try {
121
126
  const r = await pool.query('SELECT max(version) AS v FROM melchizedek_schema_version');
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * lib/storage/postgres/taskQueue.ts — the task tools' TaskBackend on
3
- * Postgres (migration 0009, ADR 0021).
3
+ * Postgres (migrations 0009 and 0014, ADR 0021, ADR 0113).
4
4
  *
5
5
  * Every rule stays in lib/tools/taskTools.ts and runs on a TaskStore
6
6
  * snapshot; this file only loads one owner's snapshot and writes the change
@@ -14,9 +14,25 @@
14
14
  * claim takes the oldest queued background job of ANY owner with
15
15
  * FOR UPDATE SKIP LOCKED, so two workers never take one job, and
16
16
  * leases it to the worker until the lease runs out.
17
+ * renew extends the lease while the job is still this worker's and
18
+ * running; resolves false otherwise, so the worker stops the run
19
+ * (a task_update cancel of a running job lands here).
17
20
  * recover finds running jobs whose lease ran out (the worker died) and
18
- * applies the same interrupted-job rule the file store uses.
21
+ * applies the same interrupted-job rule the file store uses. It
22
+ * never touches a queued job or a running one whose lease is live.
23
+ *
24
+ * Checkpoints (ADR 0113): a running job's latest step checkpoint is the
25
+ * `checkpoint` column beside `record`, never inside it, so the tools never
26
+ * read it. Only the worker holding the lease writes it (saveCheckpoint). It
27
+ * survives while the job is running or queued: recover's requeue keeps it,
28
+ * so the next claim resumes from it, and claimNext leaves it; a mutate that
29
+ * moves the job to any other status, or recover failing it, clears it; a
30
+ * deleted row takes it along. A checkpoint whose JSON is above the cap
31
+ * (TaskBackendOptions.checkpointMaxBytes, default 5 MiB) is not written:
32
+ * the column keeps the previous one, the save still reports the claim, and
33
+ * one line names the job id and the sizes.
19
34
  */
20
35
  import type { Pool } from 'pg';
21
- import type { TaskBackend } from '../../tools/taskTools.ts';
22
- export declare function postgresTaskBackend(pool: Pool): TaskBackend;
36
+ import type { TaskBackend, TaskBackendOptions } from '../../tools/taskTools.ts';
37
+ /** The task backend on `pool`; `options` caps the checkpoint a save stores (TaskBackendOptions). */
38
+ export declare function postgresTaskBackend(pool: Pool, options?: TaskBackendOptions): TaskBackend;
@@ -1,4 +1,4 @@
1
- import { applyFinish, applyInterrupted } from '../../tools/taskTools.js';
1
+ import { applyFinish, applyInterrupted, checkpointJson } from '../../tools/taskTools.js';
2
2
  const seqOf = (id) => Number(id.slice(1)) || 0;
3
3
  async function loadOwner(db, owner, lock) {
4
4
  const head = await db.query(`SELECT next_id FROM melchizedek_task_owners WHERE owner = $1${lock ? ' FOR UPDATE' : ''}`, [owner]);
@@ -21,7 +21,8 @@ async function inTransaction(pool, run) {
21
21
  client.release();
22
22
  }
23
23
  }
24
- export function postgresTaskBackend(pool) {
24
+ /** The task backend on `pool`; `options` caps the checkpoint a save stores (TaskBackendOptions). */
25
+ export function postgresTaskBackend(pool, options = {}) {
25
26
  return {
26
27
  read: (owner) => loadOwner(pool, owner, false),
27
28
  async mutate(owner, change) {
@@ -39,12 +40,15 @@ export function postgresTaskBackend(pool) {
39
40
  if (before.get(t.id) === JSON.stringify(t))
40
41
  continue;
41
42
  // Leaving `running` ends the job's lease; staying in it keeps it.
43
+ // A checkpoint survives only in running or queued.
42
44
  await client.query(`INSERT INTO melchizedek_tasks (owner, id, seq, kind, status, record, updated_at)
43
45
  VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())
44
46
  ON CONFLICT (owner, id) DO UPDATE
45
47
  SET kind = EXCLUDED.kind, status = EXCLUDED.status, record = EXCLUDED.record, updated_at = NOW(),
46
48
  lease_owner = CASE WHEN EXCLUDED.status = 'running' THEN melchizedek_tasks.lease_owner END,
47
- lease_until = CASE WHEN EXCLUDED.status = 'running' THEN melchizedek_tasks.lease_until END`, [owner, t.id, seqOf(t.id), t.kind, t.status, JSON.stringify(t)]);
49
+ lease_until = CASE WHEN EXCLUDED.status = 'running' THEN melchizedek_tasks.lease_until END,
50
+ checkpoint = CASE WHEN EXCLUDED.status IN ('running', 'queued') THEN melchizedek_tasks.checkpoint END,
51
+ checkpoint_at = CASE WHEN EXCLUDED.status IN ('running', 'queued') THEN melchizedek_tasks.checkpoint_at END`, [owner, t.id, seqOf(t.id), t.kind, t.status, JSON.stringify(t)]);
48
52
  }
49
53
  await client.query('UPDATE melchizedek_task_owners SET next_id = $2 WHERE owner = $1', [owner, store.next_id]);
50
54
  return out;
@@ -74,8 +78,25 @@ export function postgresTaskBackend(pool) {
74
78
  return row ? { ...row.record, owner: row.owner } : null;
75
79
  },
76
80
  async renew(worker, job) {
77
- await pool.query(`UPDATE melchizedek_tasks SET lease_until = NOW() + ($4::int * interval '1 millisecond')
81
+ const r = await pool.query(`UPDATE melchizedek_tasks SET lease_until = NOW() + ($4::int * interval '1 millisecond')
78
82
  WHERE owner = $1 AND id = $2 AND lease_owner = $3 AND status = 'running'`, [job.owner, job.id, worker.workerId, worker.leaseMs]);
83
+ return (r.rowCount ?? 0) > 0;
84
+ },
85
+ async saveCheckpoint(worker, job, checkpoint) {
86
+ const json = checkpointJson(job, checkpoint, options);
87
+ if (json === null) {
88
+ // Over the cap: the previous checkpoint is kept; the answer is still whether the claim holds.
89
+ const held = await pool.query(`SELECT 1 FROM melchizedek_tasks WHERE owner = $1 AND id = $2 AND lease_owner = $3 AND status = 'running'`, [job.owner, job.id, worker.workerId]);
90
+ return (held.rowCount ?? 0) > 0;
91
+ }
92
+ const r = await pool.query(`UPDATE melchizedek_tasks SET checkpoint = $4::jsonb, checkpoint_at = NOW()
93
+ WHERE owner = $1 AND id = $2 AND lease_owner = $3 AND status = 'running'`, [job.owner, job.id, worker.workerId, json]);
94
+ return (r.rowCount ?? 0) > 0;
95
+ },
96
+ async loadCheckpoint(job) {
97
+ const r = await pool.query(`SELECT checkpoint FROM melchizedek_tasks
98
+ WHERE owner = $1 AND id = $2 AND status IN ('running', 'queued')`, [job.owner, job.id]);
99
+ return r.rows[0]?.checkpoint ?? null;
79
100
  },
80
101
  async finish(job, outcome) {
81
102
  await this.mutate(job.owner, (store) => applyFinish(store, job.id, outcome));
@@ -91,7 +112,10 @@ export function postgresTaskBackend(pool) {
91
112
  const t = row.record;
92
113
  const outcome = applyInterrupted(t);
93
114
  (outcome === 'failed' ? failed : requeued).push(`${row.owner || '(shared)'}:${t.id}`);
94
- await client.query(`UPDATE melchizedek_tasks SET status = $3, record = $4::jsonb, updated_at = NOW(), lease_owner = NULL, lease_until = NULL
115
+ // Requeued keeps the checkpoint (the next claim resumes); failed clears it.
116
+ await client.query(`UPDATE melchizedek_tasks SET status = $3, record = $4::jsonb, updated_at = NOW(), lease_owner = NULL, lease_until = NULL,
117
+ checkpoint = CASE WHEN $3::text = 'queued' THEN checkpoint END,
118
+ checkpoint_at = CASE WHEN $3::text = 'queued' THEN checkpoint_at END
95
119
  WHERE owner = $1 AND id = $2`, [row.owner, row.id, t.status, JSON.stringify(t)]);
96
120
  }
97
121
  return { requeued, failed };
@@ -8,6 +8,16 @@
8
8
  * default resolver reads the authenticated user name), so one caller cannot
9
9
  * load or list another's task. `list` mirrors the SDK's InMemoryTaskStore:
10
10
  * same filters, same newest-first order, same page-token format.
11
+ *
12
+ * Cancel across replicas (migration 0014, ADR 0113). The SDK cancels a task
13
+ * through its own event bus when the task runs on this instance; otherwise
14
+ * it writes `canceled` into this store itself. When the row is still leased
15
+ * to another live instance, that write also stamps `cancel_requested_at`
16
+ * and leaves the lease with the instance running the task: its heartbeat
17
+ * (`cancelRequestedTasks`) finds the request and aborts the run. From then
18
+ * on the row stays `canceled` — the running instance's later saves renew or
19
+ * clear its lease but never overwrite the state it reported — so a client
20
+ * told `canceled` is never later shown `completed`.
11
21
  */
12
22
  import type { Pool } from 'pg';
13
23
  import type { TaskStore } from '@a2a-js/sdk/server';
@@ -31,13 +41,19 @@ export interface TaskLease {
31
41
  instanceId: string;
32
42
  ttlMs: number;
33
43
  }
44
+ /**
45
+ * Ids of the running tasks leased to this instance that a caller asked,
46
+ * through another instance, to cancel. The lease heartbeat aborts each.
47
+ */
48
+ export declare function cancelRequestedTasks(pool: Pool, lease: TaskLease): Promise<string[]>;
34
49
  /** Extends every lease this instance holds. Returns how many it renewed. */
35
50
  export declare function renewTaskLeases(pool: Pool, lease: TaskLease): Promise<number>;
36
51
  /**
37
52
  * Marks running tasks whose lease expired (their instance died) as failed,
38
53
  * with a message saying so. Any instance may run it: rows are claimed with
39
- * SKIP LOCKED, so two reapers never fail the same task twice. Returns how
40
- * many it failed.
54
+ * SKIP LOCKED, so two reapers never fail the same task twice. A task whose
55
+ * cancel was already reported keeps `canceled`: only its lease is cleared.
56
+ * Returns how many it failed.
41
57
  */
42
58
  export declare function reapExpiredTasks(pool: Pool, limit?: number): Promise<number>;
43
59
  export declare class PostgresTaskStore implements TaskStore {
@@ -49,6 +65,14 @@ export declare class PostgresTaskStore implements TaskStore {
49
65
  constructor(pool: Pool, agentId: string, options?: PostgresTaskStoreOptions);
50
66
  private scope;
51
67
  save(task: Task, context: Context): Promise<void>;
68
+ /**
69
+ * Asks the instance running a task to cancel it, without changing the
70
+ * task: stamps `cancel_requested_at` on the caller's own row (scoped as
71
+ * `load` is) while it is running and leased. The running instance's lease
72
+ * heartbeat aborts the run, which then ends `canceled`. Returns whether a
73
+ * running task of this caller's was found.
74
+ */
75
+ requestCancel(taskId: string, context: Context): Promise<boolean>;
52
76
  load(taskId: string, context: Context): Promise<Task | undefined>;
53
77
  list(params: ListParams, context: Context): Promise<ListResult>;
54
78
  }
@@ -8,6 +8,16 @@
8
8
  * default resolver reads the authenticated user name), so one caller cannot
9
9
  * load or list another's task. `list` mirrors the SDK's InMemoryTaskStore:
10
10
  * same filters, same newest-first order, same page-token format.
11
+ *
12
+ * Cancel across replicas (migration 0014, ADR 0113). The SDK cancels a task
13
+ * through its own event bus when the task runs on this instance; otherwise
14
+ * it writes `canceled` into this store itself. When the row is still leased
15
+ * to another live instance, that write also stamps `cancel_requested_at`
16
+ * and leaves the lease with the instance running the task: its heartbeat
17
+ * (`cancelRequestedTasks`) finds the request and aborts the run. From then
18
+ * on the row stays `canceled` — the running instance's later saves renew or
19
+ * clear its lease but never overwrite the state it reported — so a client
20
+ * told `canceled` is never later shown `completed`.
11
21
  */
12
22
  import { randomUUID } from 'node:crypto';
13
23
  import { Role, TaskState } from '@a2a-js/sdk';
@@ -15,6 +25,24 @@ import { resolveUserScope } from '@a2a-js/sdk/server';
15
25
  const DEFAULT_PAGE_SIZE = 50;
16
26
  /** States in which a task is being worked on by some instance. */
17
27
  const RUNNING = new Set([TaskState.TASK_STATE_SUBMITTED, TaskState.TASK_STATE_WORKING]);
28
+ /** SQL: the stored row is a running task leased to another live instance. */
29
+ const HELD_ELSEWHERE = `(adk_a2a_tasks.lease_owner IS NOT NULL
30
+ AND adk_a2a_tasks.lease_owner IS DISTINCT FROM $12::text
31
+ AND adk_a2a_tasks.lease_until > now()
32
+ AND adk_a2a_tasks.state = ANY($14::int[]))`;
33
+ /** SQL: this save is a cancel, written by an instance not running the task. */
34
+ const CANCEL_FROM_ELSEWHERE = `($6::int = $13::int AND ${HELD_ELSEWHERE})`;
35
+ /** SQL: a cancel was reported for the row; its state stays `canceled`. */
36
+ const CANCEL_REPORTED = `(adk_a2a_tasks.cancel_requested_at IS NOT NULL AND adk_a2a_tasks.state = $13::int)`;
37
+ /**
38
+ * Ids of the running tasks leased to this instance that a caller asked,
39
+ * through another instance, to cancel. The lease heartbeat aborts each.
40
+ */
41
+ export async function cancelRequestedTasks(pool, lease) {
42
+ const r = await pool.query(`SELECT id FROM adk_a2a_tasks
43
+ WHERE lease_owner = $1 AND lease_until IS NOT NULL AND cancel_requested_at IS NOT NULL`, [lease.instanceId]);
44
+ return r.rows.map((row) => String(row.id));
45
+ }
18
46
  /** Extends every lease this instance holds. Returns how many it renewed. */
19
47
  export async function renewTaskLeases(pool, lease) {
20
48
  const r = await pool.query(`UPDATE adk_a2a_tasks SET lease_until = now() + ($2::int * interval '1 millisecond')
@@ -26,17 +54,25 @@ const ORPHANED = 'The server running this task stopped before it finished; send
26
54
  /**
27
55
  * Marks running tasks whose lease expired (their instance died) as failed,
28
56
  * with a message saying so. Any instance may run it: rows are claimed with
29
- * SKIP LOCKED, so two reapers never fail the same task twice. Returns how
30
- * many it failed.
57
+ * SKIP LOCKED, so two reapers never fail the same task twice. A task whose
58
+ * cancel was already reported keeps `canceled`: only its lease is cleared.
59
+ * Returns how many it failed.
31
60
  */
32
61
  export async function reapExpiredTasks(pool, limit = 100) {
33
62
  const client = await pool.connect();
34
63
  try {
35
64
  await client.query('BEGIN');
36
- const r = await client.query(`SELECT tenant, owner, agent_id, id, task FROM adk_a2a_tasks
65
+ const r = await client.query(`SELECT tenant, owner, agent_id, id, task, state FROM adk_a2a_tasks
37
66
  WHERE lease_until IS NOT NULL AND lease_until < now()
38
67
  ORDER BY lease_until LIMIT $1 FOR UPDATE SKIP LOCKED`, [limit]);
68
+ let failed = 0;
39
69
  for (const row of r.rows) {
70
+ if (!RUNNING.has(Number(row.state))) {
71
+ await client.query(`UPDATE adk_a2a_tasks SET lease_owner = NULL, lease_until = NULL, cancel_requested_at = NULL
72
+ WHERE tenant = $1 AND owner = $2 AND agent_id = $3 AND id = $4`, [row.tenant, row.owner, row.agent_id, row.id]);
73
+ continue;
74
+ }
75
+ failed += 1;
40
76
  const task = row.task;
41
77
  const timestamp = new Date().toISOString();
42
78
  task.status = {
@@ -53,11 +89,11 @@ export async function reapExpiredTasks(pool, limit = 100) {
53
89
  },
54
90
  };
55
91
  await client.query(`UPDATE adk_a2a_tasks SET task = $5::jsonb, state = $6, status_ts = $7, updated_at = now(),
56
- lease_owner = NULL, lease_until = NULL
92
+ lease_owner = NULL, lease_until = NULL, cancel_requested_at = NULL
57
93
  WHERE tenant = $1 AND owner = $2 AND agent_id = $3 AND id = $4`, [row.tenant, row.owner, row.agent_id, row.id, JSON.stringify(task), TaskState.TASK_STATE_FAILED, timestamp]);
58
94
  }
59
95
  await client.query('COMMIT');
60
- return r.rows.length;
96
+ return failed;
61
97
  }
62
98
  catch (err) {
63
99
  await client.query('ROLLBACK').catch(() => { });
@@ -97,13 +133,26 @@ export class PostgresTaskStore {
97
133
  const t = task;
98
134
  // A running task is leased to this instance; any other state holds none.
99
135
  const leased = !!this.lease && RUNNING.has(t.status?.state ?? -1);
136
+ // On conflict, three cases (see the header): a cancel written by an
137
+ // instance not running the task records the request and leaves the lease
138
+ // where it is; a row whose cancel was reported keeps its content while
139
+ // the running instance renews or clears the lease; anything else is the
140
+ // plain upsert. A pending request survives while the task runs and is
141
+ // cleared when it stops.
100
142
  await this.pool.query(`INSERT INTO adk_a2a_tasks (tenant, owner, agent_id, id, context_id, state, status_ts, task, updated_at, expire_at, lease_owner, lease_until)
101
143
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), $9, $10,
102
144
  CASE WHEN $10::text IS NULL THEN NULL ELSE now() + ($11::int * interval '1 millisecond') END)
103
145
  ON CONFLICT (tenant, owner, agent_id, id) DO UPDATE
104
- SET context_id = EXCLUDED.context_id, state = EXCLUDED.state, status_ts = EXCLUDED.status_ts,
105
- task = EXCLUDED.task, updated_at = now(), expire_at = EXCLUDED.expire_at,
106
- lease_owner = EXCLUDED.lease_owner, lease_until = EXCLUDED.lease_until`, [
146
+ SET context_id = CASE WHEN ${CANCEL_REPORTED} THEN adk_a2a_tasks.context_id ELSE EXCLUDED.context_id END,
147
+ state = CASE WHEN ${CANCEL_REPORTED} THEN adk_a2a_tasks.state ELSE EXCLUDED.state END,
148
+ status_ts = CASE WHEN ${CANCEL_REPORTED} THEN adk_a2a_tasks.status_ts ELSE EXCLUDED.status_ts END,
149
+ task = CASE WHEN ${CANCEL_REPORTED} THEN adk_a2a_tasks.task ELSE EXCLUDED.task END,
150
+ updated_at = now(), expire_at = EXCLUDED.expire_at,
151
+ lease_owner = CASE WHEN ${CANCEL_FROM_ELSEWHERE} THEN adk_a2a_tasks.lease_owner ELSE EXCLUDED.lease_owner END,
152
+ lease_until = CASE WHEN ${CANCEL_FROM_ELSEWHERE} THEN adk_a2a_tasks.lease_until ELSE EXCLUDED.lease_until END,
153
+ cancel_requested_at = CASE WHEN ${CANCEL_FROM_ELSEWHERE} THEN now()
154
+ WHEN EXCLUDED.lease_owner IS NOT NULL THEN adk_a2a_tasks.cancel_requested_at
155
+ ELSE NULL END`, [
107
156
  tenant,
108
157
  owner,
109
158
  this.agentId,
@@ -115,8 +164,25 @@ export class PostgresTaskStore {
115
164
  new Date(Date.now() + this.ttlMs).toISOString(),
116
165
  leased ? this.lease.instanceId : null,
117
166
  this.lease?.ttlMs ?? 0,
167
+ this.lease?.instanceId ?? null,
168
+ TaskState.TASK_STATE_CANCELED,
169
+ [...RUNNING],
118
170
  ]);
119
171
  }
172
+ /**
173
+ * Asks the instance running a task to cancel it, without changing the
174
+ * task: stamps `cancel_requested_at` on the caller's own row (scoped as
175
+ * `load` is) while it is running and leased. The running instance's lease
176
+ * heartbeat aborts the run, which then ends `canceled`. Returns whether a
177
+ * running task of this caller's was found.
178
+ */
179
+ async requestCancel(taskId, context) {
180
+ const [tenant, owner] = this.scope(context);
181
+ const r = await this.pool.query(`UPDATE adk_a2a_tasks SET cancel_requested_at = now()
182
+ WHERE tenant = $1 AND owner = $2 AND agent_id = $3 AND id = $4
183
+ AND lease_until IS NOT NULL AND state = ANY($5::int[])`, [tenant, owner, this.agentId, taskId, [...RUNNING]]);
184
+ return (r.rowCount ?? 0) > 0;
185
+ }
120
186
  async load(taskId, context) {
121
187
  const [tenant, owner] = this.scope(context);
122
188
  const r = await this.pool.query('SELECT task FROM adk_a2a_tasks WHERE tenant = $1 AND owner = $2 AND agent_id = $3 AND id = $4', [tenant, owner, this.agentId, taskId]);