melchizedek-agents 1.0.2 → 1.1.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 (122) hide show
  1. package/.env.example +19 -2
  2. package/CHANGELOG.md +333 -0
  3. package/DOCUMENTATION.md +99 -39
  4. package/README.md +1 -0
  5. package/config/agents/README.md +2 -2
  6. package/config/agents/examples/ares.yaml +6 -10
  7. package/config/agents/examples/assistant.yaml +6 -6
  8. package/config/agents/examples/augustin.yaml +9 -15
  9. package/config/agents/examples/cartographers.yaml +6 -6
  10. package/config/agents/examples/council.yaml +6 -6
  11. package/config/agents/examples/critic.yaml +16 -16
  12. package/config/agents/examples/harness.yaml +4 -4
  13. package/config/agents/examples/image_production.yaml +33 -32
  14. package/config/agents/examples/librarian.yaml +4 -4
  15. package/config/agents/examples/patient_advocate.yaml +6 -10
  16. package/config/agents/examples/pipeline.yaml +20 -19
  17. package/config/agents/examples/research.yaml +36 -43
  18. package/config/agents/examples/scribe.yaml +31 -32
  19. package/config/agents/examples/scriptorium.yaml +6 -6
  20. package/config/agents/examples/structured_critic.yaml +82 -0
  21. package/config/agents/examples/style_council.yaml +9 -11
  22. package/config/agents/examples/syndicate.yaml +3 -5
  23. package/config/agents/examples/tutor.yaml +2 -2
  24. package/config/agents/examples/weather.yaml +2 -2
  25. package/config/agents/syndicate.schema.json +1979 -0
  26. package/config/agents/syndicateSchema.yaml +109 -72
  27. package/config/agents/templates/README.md +1 -1
  28. package/config/agents/templates/account_memory.yaml +4 -4
  29. package/config/agents/templates/case_desk.yaml +6 -6
  30. package/config/agents/templates/conversational.yaml +2 -2
  31. package/config/agents/templates/draft_review.yaml +6 -6
  32. package/config/agents/templates/intake_extractor.yaml +61 -60
  33. package/config/agents/templates/research_brief.yaml +6 -6
  34. package/config/agents/templates/research_desk.yaml +6 -6
  35. package/config/agents/templates/review_panel.yaml +8 -8
  36. package/config/agents/templates/support_triage.yaml +30 -29
  37. package/config/agents/templates/systems_operator.yaml +21 -5
  38. package/db/migrations/0014_durable_runs.sql +31 -0
  39. package/dist/lib/a2a/app.d.ts +15 -0
  40. package/dist/lib/a2a/app.js +53 -5
  41. package/dist/lib/a2a/executor.d.ts +7 -1
  42. package/dist/lib/a2a/executor.js +34 -4
  43. package/dist/lib/a2a/oauthSetup.d.ts +90 -0
  44. package/dist/lib/a2a/oauthSetup.js +205 -0
  45. package/dist/lib/agentDialect.d.ts +66 -0
  46. package/dist/lib/agentDialect.js +134 -0
  47. package/dist/lib/compile.d.ts +10 -5
  48. package/dist/lib/compile.js +69 -37
  49. package/dist/lib/compileNative.js +2 -0
  50. package/dist/lib/dispatch.d.ts +10 -5
  51. package/dist/lib/dispatch.js +10 -5
  52. package/dist/lib/doctor.d.ts +34 -1
  53. package/dist/lib/doctor.js +47 -3
  54. package/dist/lib/loadSyndicate.d.ts +45 -2
  55. package/dist/lib/loadSyndicate.js +22 -2
  56. package/dist/lib/memory/namespace.d.ts +6 -5
  57. package/dist/lib/memory/namespace.js +6 -5
  58. package/dist/lib/memory/providers.js +3 -4
  59. package/dist/lib/models/capabilities.d.ts +16 -1
  60. package/dist/lib/models/capabilities.js +55 -1
  61. package/dist/lib/observability/tracer.d.ts +8 -1
  62. package/dist/lib/observability/tracer.js +33 -5
  63. package/dist/lib/runtime/approvals.d.ts +6 -0
  64. package/dist/lib/runtime/native/agentLoop.d.ts +18 -1
  65. package/dist/lib/runtime/native/agentLoop.js +105 -11
  66. package/dist/lib/runtime/native/checkpoint.d.ts +164 -0
  67. package/dist/lib/runtime/native/checkpoint.js +365 -0
  68. package/dist/lib/runtime/native/delegate.d.ts +141 -18
  69. package/dist/lib/runtime/native/delegate.js +235 -39
  70. package/dist/lib/runtime/native/interrupts.d.ts +101 -2
  71. package/dist/lib/runtime/native/interrupts.js +201 -1
  72. package/dist/lib/runtime/native/request.d.ts +13 -3
  73. package/dist/lib/runtime/native/request.js +28 -4
  74. package/dist/lib/runtime/native/telemetry.js +10 -15
  75. package/dist/lib/runtime/questions.d.ts +7 -4
  76. package/dist/lib/runtime/sessions.d.ts +1 -1
  77. package/dist/lib/runtime/syndicateTurn.d.ts +7 -1
  78. package/dist/lib/runtime/syndicateTurn.js +74 -22
  79. package/dist/lib/runtime/turnControl.d.ts +15 -0
  80. package/dist/lib/runtime/turnControl.js +10 -0
  81. package/dist/lib/storage/postgres/index.d.ts +6 -1
  82. package/dist/lib/storage/postgres/index.js +7 -2
  83. package/dist/lib/storage/postgres/taskQueue.d.ts +14 -2
  84. package/dist/lib/storage/postgres/taskQueue.js +20 -3
  85. package/dist/lib/storage/postgres/taskStore.d.ts +26 -2
  86. package/dist/lib/storage/postgres/taskStore.js +74 -8
  87. package/dist/lib/syndicateSchema.d.ts +278 -0
  88. package/dist/lib/syndicateSchema.js +182 -15
  89. package/dist/lib/tools/auth.d.ts +7 -5
  90. package/dist/lib/tools/auth.js +12 -4
  91. package/dist/lib/tools/credentialEnv.d.ts +28 -0
  92. package/dist/lib/tools/credentialEnv.js +52 -0
  93. package/dist/lib/tools/credentialStore.js +9 -2
  94. package/dist/lib/tools/mcpToolFactory.d.ts +25 -2
  95. package/dist/lib/tools/mcpToolFactory.js +179 -21
  96. package/dist/lib/tools/oauthHosts.d.ts +71 -0
  97. package/dist/lib/tools/oauthHosts.js +154 -0
  98. package/dist/lib/tools/oauthTools.d.ts +233 -0
  99. package/dist/lib/tools/oauthTools.js +421 -0
  100. package/dist/lib/tools/openapiTools.d.ts +4 -6
  101. package/dist/lib/tools/openapiTools.js +40 -36
  102. package/dist/lib/tools/taskTools.d.ts +30 -5
  103. package/dist/lib/tools/taskTools.js +74 -10
  104. package/dist/lib/workflow.d.ts +8 -4
  105. package/dist/lib/workflow.js +8 -4
  106. package/dist/lib/workflowConfig.d.ts +6 -0
  107. package/dist/scripts/a2a_server.d.ts +36 -0
  108. package/dist/scripts/a2a_server.js +122 -1
  109. package/dist/scripts/assistant_worker.d.ts +10 -0
  110. package/dist/scripts/assistant_worker.js +69 -19
  111. package/dist/scripts/doctor.d.ts +4 -1
  112. package/dist/scripts/doctor.js +5 -2
  113. package/dist/scripts/yaml_codemod.d.ts +52 -0
  114. package/dist/scripts/yaml_codemod.js +570 -0
  115. package/package.json +11 -7
  116. package/skills/briefs/melchizedek-author.md +7 -5
  117. package/skills/briefs/melchizedek-models.md +4 -3
  118. package/skills/briefs/melchizedek.md +1 -0
  119. package/skills/melchizedek/SKILL.md +1 -0
  120. package/skills/melchizedek-author/SKILL.md +7 -4
  121. package/skills/melchizedek-author/assets/minimal.yaml +13 -13
  122. package/skills/melchizedek-models/SKILL.md +6 -3
@@ -35,6 +35,12 @@ 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. */
@@ -46,6 +52,8 @@ export interface TurnControlOptions {
46
52
  deadlineMs?: number;
47
53
  /** An outer signal (a cancel request); aborting it cancels the turn. */
48
54
  signal?: AbortSignal;
55
+ /** The turn records no spans (TurnControl.untraced). */
56
+ untraced?: boolean;
49
57
  }
50
58
  /**
51
59
  * A control for one turn. Call `dispose()` when the turn ends so the
@@ -60,6 +68,13 @@ export declare function stopMessage(reason: TurnStopReason, control?: Pick<TurnC
60
68
  export declare function runWithTurnControl<T>(control: TurnControl, fn: () => T): T;
61
69
  /** The current turn's control, if this code is running inside a turn. */
62
70
  export declare function currentTurnControl(): TurnControl | undefined;
71
+ /**
72
+ * Whether the code running now belongs to a turn that opted out of tracing.
73
+ * Every span the engine opens checks this first (lib/observability/
74
+ * tracer.ts, lib/runtime/native/telemetry.ts): an untraced turn opens no
75
+ * span and does not start the tracer. Outside a turn: false.
76
+ */
77
+ export declare function turnUntraced(): boolean;
63
78
  /** The current turn's abort signal, for adapters to pass to provider SDKs. */
64
79
  export declare function currentTurnSignal(): AbortSignal | undefined;
65
80
  /**
@@ -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;
@@ -99,12 +99,17 @@ export interface PostgresStorage {
99
99
  turnLock: TurnLock;
100
100
  /** The task tools' lists and job queue (lib/tools/taskTools.ts setTaskBackend). */
101
101
  taskQueue: TaskBackend;
102
- /** Task leases: renew this instance's, fail other instances' expired ones. */
102
+ /**
103
+ * Task leases: renew this instance's, fail other instances' expired ones,
104
+ * and list this instance's running tasks a caller asked another instance
105
+ * to cancel (migration 0014, ADR 0113).
106
+ */
103
107
  leases: {
104
108
  instanceId: string;
105
109
  ttlMs: number;
106
110
  renew: () => Promise<number>;
107
111
  reap: () => Promise<number>;
112
+ cancelRequested?: () => Promise<string[]>;
108
113
  };
109
114
  /** Closes the pool when this module created it, and the lock pool. */
110
115
  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,7 +114,12 @@ 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) },
117
+ leases: {
118
+ ...lease,
119
+ renew: () => renewTaskLeases(pool, lease),
120
+ reap: () => reapExpiredTasks(pool),
121
+ cancelRequested: () => cancelRequestedTasks(pool, lease),
122
+ },
118
123
  taskQueue: postgresTaskBackend(pool),
119
124
  async schemaVersion() {
120
125
  try {
@@ -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,8 +14,20 @@
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.
19
31
  */
20
32
  import type { Pool } from 'pg';
21
33
  import type { TaskBackend } from '../../tools/taskTools.ts';
@@ -39,12 +39,15 @@ export function postgresTaskBackend(pool) {
39
39
  if (before.get(t.id) === JSON.stringify(t))
40
40
  continue;
41
41
  // Leaving `running` ends the job's lease; staying in it keeps it.
42
+ // A checkpoint survives only in running or queued.
42
43
  await client.query(`INSERT INTO melchizedek_tasks (owner, id, seq, kind, status, record, updated_at)
43
44
  VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())
44
45
  ON CONFLICT (owner, id) DO UPDATE
45
46
  SET kind = EXCLUDED.kind, status = EXCLUDED.status, record = EXCLUDED.record, updated_at = NOW(),
46
47
  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)]);
48
+ lease_until = CASE WHEN EXCLUDED.status = 'running' THEN melchizedek_tasks.lease_until END,
49
+ checkpoint = CASE WHEN EXCLUDED.status IN ('running', 'queued') THEN melchizedek_tasks.checkpoint END,
50
+ 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
51
  }
49
52
  await client.query('UPDATE melchizedek_task_owners SET next_id = $2 WHERE owner = $1', [owner, store.next_id]);
50
53
  return out;
@@ -74,8 +77,19 @@ export function postgresTaskBackend(pool) {
74
77
  return row ? { ...row.record, owner: row.owner } : null;
75
78
  },
76
79
  async renew(worker, job) {
77
- await pool.query(`UPDATE melchizedek_tasks SET lease_until = NOW() + ($4::int * interval '1 millisecond')
80
+ const r = await pool.query(`UPDATE melchizedek_tasks SET lease_until = NOW() + ($4::int * interval '1 millisecond')
78
81
  WHERE owner = $1 AND id = $2 AND lease_owner = $3 AND status = 'running'`, [job.owner, job.id, worker.workerId, worker.leaseMs]);
82
+ return (r.rowCount ?? 0) > 0;
83
+ },
84
+ async saveCheckpoint(worker, job, checkpoint) {
85
+ const r = await pool.query(`UPDATE melchizedek_tasks SET checkpoint = $4::jsonb, checkpoint_at = NOW()
86
+ WHERE owner = $1 AND id = $2 AND lease_owner = $3 AND status = 'running'`, [job.owner, job.id, worker.workerId, JSON.stringify(checkpoint)]);
87
+ return (r.rowCount ?? 0) > 0;
88
+ },
89
+ async loadCheckpoint(job) {
90
+ const r = await pool.query(`SELECT checkpoint FROM melchizedek_tasks
91
+ WHERE owner = $1 AND id = $2 AND status IN ('running', 'queued')`, [job.owner, job.id]);
92
+ return r.rows[0]?.checkpoint ?? null;
79
93
  },
80
94
  async finish(job, outcome) {
81
95
  await this.mutate(job.owner, (store) => applyFinish(store, job.id, outcome));
@@ -91,7 +105,10 @@ export function postgresTaskBackend(pool) {
91
105
  const t = row.record;
92
106
  const outcome = applyInterrupted(t);
93
107
  (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
108
+ // Requeued keeps the checkpoint (the next claim resumes); failed clears it.
109
+ await client.query(`UPDATE melchizedek_tasks SET status = $3, record = $4::jsonb, updated_at = NOW(), lease_owner = NULL, lease_until = NULL,
110
+ checkpoint = CASE WHEN $3::text = 'queued' THEN checkpoint END,
111
+ checkpoint_at = CASE WHEN $3::text = 'queued' THEN checkpoint_at END
95
112
  WHERE owner = $1 AND id = $2`, [row.owner, row.id, t.status, JSON.stringify(t)]);
96
113
  }
97
114
  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]);