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.
- package/.env.example +29 -2
- package/AGENT_SETUP.md +3 -1
- package/CHANGELOG.md +524 -0
- package/DOCUMENTATION.md +178 -43
- package/ONBOARDING.md +339 -0
- package/QUICKSTART.md +15 -2
- package/README.md +6 -2
- package/config/agents/README.md +2 -2
- package/config/agents/examples/ares.yaml +6 -10
- package/config/agents/examples/assistant.yaml +6 -6
- package/config/agents/examples/augustin.yaml +9 -15
- package/config/agents/examples/cartographers.yaml +6 -6
- package/config/agents/examples/council.yaml +7 -7
- package/config/agents/examples/critic.yaml +24 -24
- package/config/agents/examples/harness.yaml +4 -4
- package/config/agents/examples/image_production.yaml +37 -35
- package/config/agents/examples/librarian.yaml +4 -4
- package/config/agents/examples/patient_advocate.yaml +6 -10
- package/config/agents/examples/pipeline.yaml +20 -19
- package/config/agents/examples/research.yaml +36 -43
- package/config/agents/examples/scribe.yaml +31 -32
- package/config/agents/examples/scriptorium.yaml +6 -6
- package/config/agents/examples/structured_critic.yaml +82 -0
- package/config/agents/examples/style_council.yaml +9 -11
- package/config/agents/examples/syndicate.yaml +3 -5
- package/config/agents/examples/tutor.yaml +2 -2
- package/config/agents/examples/weather.yaml +2 -2
- package/config/agents/syndicate.schema.json +859 -13
- package/config/agents/syndicateSchema.yaml +122 -73
- package/config/agents/templates/README.md +1 -1
- package/config/agents/templates/account_memory.yaml +4 -4
- package/config/agents/templates/case_desk.yaml +6 -6
- package/config/agents/templates/conversational.yaml +2 -2
- package/config/agents/templates/draft_review.yaml +6 -6
- package/config/agents/templates/intake_extractor.yaml +64 -62
- package/config/agents/templates/research_brief.yaml +6 -6
- package/config/agents/templates/research_desk.yaml +6 -6
- package/config/agents/templates/review_panel.yaml +8 -8
- package/config/agents/templates/support_triage.yaml +30 -29
- package/config/agents/templates/systems_operator.yaml +24 -5
- package/db/migrations/0014_durable_runs.sql +31 -0
- package/dist/lib/a2a/app.d.ts +27 -0
- package/dist/lib/a2a/app.js +64 -5
- package/dist/lib/a2a/executor.d.ts +7 -1
- package/dist/lib/a2a/executor.js +43 -5
- package/dist/lib/a2a/oauthSetup.d.ts +106 -0
- package/dist/lib/a2a/oauthSetup.js +259 -0
- package/dist/lib/agentDialect.d.ts +76 -0
- package/dist/lib/agentDialect.js +151 -0
- package/dist/lib/compile.d.ts +42 -6
- package/dist/lib/compile.js +97 -45
- package/dist/lib/compileNative.d.ts +11 -3
- package/dist/lib/compileNative.js +20 -1
- package/dist/lib/dispatch.d.ts +10 -5
- package/dist/lib/dispatch.js +10 -5
- package/dist/lib/doctor.d.ts +110 -1
- package/dist/lib/doctor.js +205 -9
- package/dist/lib/loadSyndicate.d.ts +54 -2
- package/dist/lib/loadSyndicate.js +22 -2
- package/dist/lib/memory/namespace.d.ts +6 -5
- package/dist/lib/memory/namespace.js +6 -5
- package/dist/lib/memory/providers.js +3 -4
- package/dist/lib/models/capabilities.d.ts +25 -1
- package/dist/lib/models/capabilities.js +79 -1
- package/dist/lib/models/chatCompletionsAdapter.d.ts +11 -3
- package/dist/lib/models/chatCompletionsAdapter.js +19 -5
- package/dist/lib/models/claudeAdapter.js +4 -0
- package/dist/lib/models/contract.d.ts +5 -2
- package/dist/lib/models/gateway.d.ts +2 -1
- package/dist/lib/models/gateway.js +4 -2
- package/dist/lib/models/gatewayAdapter.d.ts +7 -1
- package/dist/lib/models/gatewayAdapter.js +8 -0
- package/dist/lib/models/geminiAdapter.js +8 -2
- package/dist/lib/models/genaiMapping.d.ts +8 -4
- package/dist/lib/models/genaiMapping.js +23 -9
- package/dist/lib/models/gptAdapter.d.ts +4 -2
- package/dist/lib/models/gptAdapter.js +11 -4
- package/dist/lib/models/grokAdapter.d.ts +3 -1
- package/dist/lib/models/grokAdapter.js +11 -3
- package/dist/lib/models/kimiAdapter.d.ts +2 -1
- package/dist/lib/models/kimiAdapter.js +3 -3
- package/dist/lib/models/reasoning.d.ts +30 -2
- package/dist/lib/models/reasoning.js +56 -5
- package/dist/lib/observability/tracer.d.ts +8 -1
- package/dist/lib/observability/tracer.js +33 -5
- package/dist/lib/onboarding.d.ts +134 -0
- package/dist/lib/onboarding.js +539 -0
- package/dist/lib/runtime/approvals.d.ts +8 -0
- package/dist/lib/runtime/approvals.js +4 -3
- package/dist/lib/runtime/credentials.d.ts +6 -0
- package/dist/lib/runtime/native/agentLoop.d.ts +23 -3
- package/dist/lib/runtime/native/agentLoop.js +107 -11
- package/dist/lib/runtime/native/checkpoint.d.ts +171 -0
- package/dist/lib/runtime/native/checkpoint.js +359 -0
- package/dist/lib/runtime/native/delegate.d.ts +222 -18
- package/dist/lib/runtime/native/delegate.js +390 -39
- package/dist/lib/runtime/native/interrupts.d.ts +184 -2
- package/dist/lib/runtime/native/interrupts.js +391 -1
- package/dist/lib/runtime/native/request.d.ts +13 -3
- package/dist/lib/runtime/native/request.js +28 -4
- package/dist/lib/runtime/native/telemetry.js +10 -15
- package/dist/lib/runtime/questions.d.ts +7 -4
- package/dist/lib/runtime/sessions.d.ts +1 -1
- package/dist/lib/runtime/syndicateTurn.d.ts +11 -3
- package/dist/lib/runtime/syndicateTurn.js +353 -68
- package/dist/lib/runtime/turnControl.d.ts +56 -0
- package/dist/lib/runtime/turnControl.js +10 -0
- package/dist/lib/session/transcript.d.ts +9 -0
- package/dist/lib/session/transcript.js +20 -1
- package/dist/lib/skills.d.ts +18 -0
- package/dist/lib/skills.js +73 -1
- package/dist/lib/storage/postgres/index.d.ts +12 -2
- package/dist/lib/storage/postgres/index.js +8 -3
- package/dist/lib/storage/postgres/taskQueue.d.ts +20 -4
- package/dist/lib/storage/postgres/taskQueue.js +29 -5
- package/dist/lib/storage/postgres/taskStore.d.ts +26 -2
- package/dist/lib/storage/postgres/taskStore.js +74 -8
- package/dist/lib/syndicateSchema.d.ts +316 -1
- package/dist/lib/syndicateSchema.js +203 -29
- package/dist/lib/tools/auth.d.ts +20 -6
- package/dist/lib/tools/auth.js +21 -5
- package/dist/lib/tools/credentialEnv.d.ts +28 -0
- package/dist/lib/tools/credentialEnv.js +52 -0
- package/dist/lib/tools/credentialHosts.d.ts +88 -0
- package/dist/lib/tools/credentialHosts.js +165 -0
- package/dist/lib/tools/credentialStore.js +9 -2
- package/dist/lib/tools/credentialUses.d.ts +92 -0
- package/dist/lib/tools/credentialUses.js +112 -0
- package/dist/lib/tools/mcpToolFactory.d.ts +25 -2
- package/dist/lib/tools/mcpToolFactory.js +179 -21
- package/dist/lib/tools/oauthConsent.js +8 -1
- package/dist/lib/tools/oauthHosts.d.ts +73 -0
- package/dist/lib/tools/oauthHosts.js +154 -0
- package/dist/lib/tools/oauthTools.d.ts +242 -0
- package/dist/lib/tools/oauthTools.js +447 -0
- package/dist/lib/tools/openapiTools.d.ts +11 -6
- package/dist/lib/tools/openapiTools.js +88 -38
- package/dist/lib/tools/taskTools.d.ts +61 -6
- package/dist/lib/tools/taskTools.js +145 -21
- package/dist/lib/workflow/turn.d.ts +22 -3
- package/dist/lib/workflow/turn.js +151 -31
- package/dist/lib/workflow.d.ts +14 -7
- package/dist/lib/workflow.js +14 -7
- package/dist/lib/workflowConfig.d.ts +6 -0
- package/dist/scripts/a2a_server.d.ts +36 -0
- package/dist/scripts/a2a_server.js +131 -1
- package/dist/scripts/assistant_worker.d.ts +12 -0
- package/dist/scripts/assistant_worker.js +81 -22
- package/dist/scripts/doctor.d.ts +7 -2
- package/dist/scripts/doctor.js +9 -4
- package/dist/scripts/setup.d.ts +23 -0
- package/dist/scripts/setup.js +154 -0
- package/dist/scripts/skills_install.d.ts +3 -1
- package/dist/scripts/skills_install.js +17 -2
- package/dist/scripts/yaml_codemod.d.ts +62 -0
- package/dist/scripts/yaml_codemod.js +652 -0
- package/package.json +8 -2
- package/skills/README.md +11 -2
- package/skills/briefs/_shared.md +1 -1
- package/skills/briefs/melchizedek-author.md +7 -5
- package/skills/briefs/melchizedek-models.md +7 -4
- package/skills/briefs/melchizedek.md +1 -0
- package/skills/briefs/skills-readme.md +3 -1
- package/skills/melchizedek/SKILL.md +1 -0
- package/skills/melchizedek-author/SKILL.md +7 -4
- package/skills/melchizedek-author/assets/minimal.yaml +13 -13
- package/skills/melchizedek-models/SKILL.md +11 -4
- package/skills/melchizedek-onboard/SKILL.md +49 -0
- package/skills/melchizedek-onboard-cloud/SKILL.md +38 -0
- package/skills/melchizedek-onboard-keys/SKILL.md +35 -0
- package/skills/melchizedek-onboard-local/SKILL.md +41 -0
- 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
|
-
|
|
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
|
}
|
package/dist/lib/skills.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/skills.js
CHANGED
|
@@ -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
|
-
/**
|
|
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: {
|
|
118
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
40
|
-
*
|
|
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.
|
|
30
|
-
*
|
|
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
|
|
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 =
|
|
105
|
-
|
|
106
|
-
|
|
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]);
|