@ziggs-ai/agent-sdk 0.9.0 → 0.10.1
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/CHANGELOG.md +23 -0
- package/dist/AgentHost.d.ts +53 -14
- package/dist/AgentHost.js +137 -8
- package/dist/agent/ClaudeSdkAgent.d.ts +27 -2
- package/dist/agent/ClaudeSdkAgent.js +151 -78
- package/dist/agent/claudeSdkTools.js +39 -11
- package/dist/context/applyEffects.js +4 -3
- package/dist/defineAgent.js +4 -1
- package/dist/formatters/HistoryFormatter.js +11 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/ingress/normalizeIncoming.d.ts +5 -0
- package/dist/ingress/normalizeIncoming.js +23 -1
- package/dist/pricing/fleetDefaults.d.ts +0 -3
- package/dist/pricing/fleetDefaults.js +0 -6
- package/dist/runtime/PromptBuilder.js +9 -5
- package/dist/runtime/bootstrapCtx.js +6 -8
- package/dist/runtime/buildOutcome.d.ts +9 -1
- package/dist/runtime/buildOutcome.js +29 -6
- package/dist/runtime/runTurn.js +1 -1
- package/dist/runtime/taskBookkeeping.d.ts +0 -2
- package/dist/runtime/taskBookkeeping.js +0 -6
- package/dist/runtime/taskIntake.js +6 -4
- package/dist/runtime/wakeWriteKeys.js +23 -21
- package/dist/server/ConnectionPool.d.ts +10 -52
- package/dist/server/ConnectionPool.js +26 -57
- package/dist/server/EventQueue.d.ts +6 -0
- package/dist/server/EventQueue.js +21 -6
- package/dist/server/SeenMessages.d.ts +7 -0
- package/dist/server/SeenMessages.js +15 -0
- package/dist/server/ZiggsEffectHandler.d.ts +1 -0
- package/dist/server/ZiggsEffectHandler.js +21 -11
- package/dist/server/inboxLoop.d.ts +17 -2
- package/dist/server/inboxLoop.js +92 -16
- package/dist/server/polls/PollKind.d.ts +72 -0
- package/dist/server/polls/PollKind.js +53 -0
- package/dist/server/polls/PollRunner.d.ts +62 -0
- package/dist/server/polls/PollRunner.js +138 -0
- package/dist/server/questMatch.d.ts +5 -0
- package/dist/server/questMatch.js +14 -0
- package/dist/server/runLauncher.d.ts +0 -6
- package/dist/server/runLauncher.js +54 -27
- package/dist/server/tasks/TaskService.d.ts +2 -11
- package/dist/server/tools/tier1/protocolRunner.d.ts +3 -1
- package/dist/server/tools/tier1/protocolRunner.js +40 -41
- package/dist/server/tools/tier1/protocolTools.js +23 -13
- package/dist/server/tools/tier2/connectionTools.js +12 -1
- package/dist/tasks/protocolRegistry.d.ts +27 -0
- package/dist/tasks/protocolRegistry.js +36 -0
- package/dist/tools/capabilityAdapter.js +12 -1
- package/dist/tools/recordReport.js +8 -4
- package/dist/types.d.ts +24 -11
- package/package.json +2 -2
- package/dist/server/staleBacklogGate.d.ts +0 -57
- package/dist/server/staleBacklogGate.js +0 -100
|
@@ -7,7 +7,13 @@ import { toolFromCapability } from '../../../tools/capabilityAdapter.js';
|
|
|
7
7
|
// allowance in the parity table).
|
|
8
8
|
export const connectionProxyTool = toolFromCapability(connectionProxyCapability);
|
|
9
9
|
export const requestConnectionTool = toolFromCapability(requestConnectionCapability);
|
|
10
|
-
export const connectionListGrantsTool = defineTool('connection_list_grants', {
|
|
10
|
+
export const connectionListGrantsTool = defineTool('connection_list_grants', {
|
|
11
|
+
connectionId: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
required: true,
|
|
14
|
+
description: 'The stored connection to list grants for.',
|
|
15
|
+
},
|
|
16
|
+
}, async (args, ctx) => {
|
|
11
17
|
if (!args['connectionId'])
|
|
12
18
|
throw new Error('connectionId is required');
|
|
13
19
|
const operatorKey = ctx?.['operatorKey'];
|
|
@@ -28,6 +34,11 @@ export const connectionListGrantsTool = defineTool('connection_list_grants', { c
|
|
|
28
34
|
wrapped.body = err.body;
|
|
29
35
|
throw wrapped;
|
|
30
36
|
}
|
|
37
|
+
}, {
|
|
38
|
+
// ZIG-1128: this shipped with no description at all — the model was handed a
|
|
39
|
+
// name and a parameter and left to guess what the tool was for. Found by the
|
|
40
|
+
// new SDK tool-text guard.
|
|
41
|
+
description: "List the grants issued to this agent on one stored connection. Returns the grantId that connection_proxy needs. Use it when you have a connectionId but not the grantId — grant_list (scopeKind=connection) is the wider read across every connection.",
|
|
31
42
|
});
|
|
32
43
|
export const CONNECTION_TOOLS = [
|
|
33
44
|
connectionProxyTool,
|
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The protocol verbs and the state operation each one performs.
|
|
3
|
+
*
|
|
4
|
+
* This is the single place the set is written down (ZIG-1110). It used to be
|
|
5
|
+
* restated: the tool definitions passed their operation string to `handlerFor`
|
|
6
|
+
* by hand, and this table listed them again. `agreement_fulfill` was in the
|
|
7
|
+
* first list and not the second, so it dispatched fine but
|
|
8
|
+
* `isProtocolToolName` said no — and at every consumer (applyEffects,
|
|
9
|
+
* routingLabels, buildOutcome) the verb that ends an engagement permanently
|
|
10
|
+
* degraded to an unlabelled plain tool-result whose outcome never reached
|
|
11
|
+
* context.
|
|
12
|
+
*
|
|
13
|
+
* A protocol tool now takes its handler from this table BY TOOL NAME, and a
|
|
14
|
+
* name that is absent throws while the module is loading. Adding a verb in one
|
|
15
|
+
* place can no longer half-register it: the tool cannot be constructed at all
|
|
16
|
+
* until it appears here.
|
|
17
|
+
*
|
|
18
|
+
* This module deliberately imports nothing. It is read from the context and
|
|
19
|
+
* runtime paths, which must not pull in the server tool chain.
|
|
20
|
+
*/
|
|
1
21
|
export declare const PROTOCOL_TOOL_TO_OPERATION: Readonly<{
|
|
2
22
|
readonly agreement_propose: "agreement-propose";
|
|
3
23
|
readonly agreement_subcontract: "agreement-subcontract";
|
|
@@ -6,6 +26,7 @@ export declare const PROTOCOL_TOOL_TO_OPERATION: Readonly<{
|
|
|
6
26
|
readonly agreement_counter: "agreement-counter-proposal";
|
|
7
27
|
readonly agreement_get: "agreement-check-proposal";
|
|
8
28
|
readonly agreement_revoke: "agreement-revoke";
|
|
29
|
+
readonly agreement_fulfill: "agreement-fulfill";
|
|
9
30
|
readonly task_create: "task-spawn";
|
|
10
31
|
readonly task_set_result: "task-update";
|
|
11
32
|
readonly task_update_plan_step: "task-update-plan-step";
|
|
@@ -15,3 +36,9 @@ export type ProtocolOperation = (typeof PROTOCOL_TOOL_TO_OPERATION)[ProtocolTool
|
|
|
15
36
|
export declare const PROTOCOL_TOOL_NAMES: readonly ProtocolToolName[];
|
|
16
37
|
export declare function mapProtocolToolToOperation(toolName: string): ProtocolOperation | null;
|
|
17
38
|
export declare function isProtocolToolName(toolName: string): toolName is ProtocolToolName;
|
|
39
|
+
/**
|
|
40
|
+
* The operation a protocol tool performs, or a throw. Called at module load by
|
|
41
|
+
* the tool definitions, so a verb defined without a registry entry takes the
|
|
42
|
+
* import down instead of shipping half-registered.
|
|
43
|
+
*/
|
|
44
|
+
export declare function requireProtocolOperation(toolName: string): ProtocolOperation;
|
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The protocol verbs and the state operation each one performs.
|
|
3
|
+
*
|
|
4
|
+
* This is the single place the set is written down (ZIG-1110). It used to be
|
|
5
|
+
* restated: the tool definitions passed their operation string to `handlerFor`
|
|
6
|
+
* by hand, and this table listed them again. `agreement_fulfill` was in the
|
|
7
|
+
* first list and not the second, so it dispatched fine but
|
|
8
|
+
* `isProtocolToolName` said no — and at every consumer (applyEffects,
|
|
9
|
+
* routingLabels, buildOutcome) the verb that ends an engagement permanently
|
|
10
|
+
* degraded to an unlabelled plain tool-result whose outcome never reached
|
|
11
|
+
* context.
|
|
12
|
+
*
|
|
13
|
+
* A protocol tool now takes its handler from this table BY TOOL NAME, and a
|
|
14
|
+
* name that is absent throws while the module is loading. Adding a verb in one
|
|
15
|
+
* place can no longer half-register it: the tool cannot be constructed at all
|
|
16
|
+
* until it appears here.
|
|
17
|
+
*
|
|
18
|
+
* This module deliberately imports nothing. It is read from the context and
|
|
19
|
+
* runtime paths, which must not pull in the server tool chain.
|
|
20
|
+
*/
|
|
1
21
|
export const PROTOCOL_TOOL_TO_OPERATION = Object.freeze({
|
|
2
22
|
agreement_propose: 'agreement-propose',
|
|
3
23
|
agreement_subcontract: 'agreement-subcontract',
|
|
@@ -6,6 +26,7 @@ export const PROTOCOL_TOOL_TO_OPERATION = Object.freeze({
|
|
|
6
26
|
agreement_counter: 'agreement-counter-proposal',
|
|
7
27
|
agreement_get: 'agreement-check-proposal',
|
|
8
28
|
agreement_revoke: 'agreement-revoke',
|
|
29
|
+
agreement_fulfill: 'agreement-fulfill',
|
|
9
30
|
task_create: 'task-spawn',
|
|
10
31
|
task_set_result: 'task-update',
|
|
11
32
|
task_update_plan_step: 'task-update-plan-step',
|
|
@@ -19,3 +40,18 @@ export function mapProtocolToolToOperation(toolName) {
|
|
|
19
40
|
export function isProtocolToolName(toolName) {
|
|
20
41
|
return mapProtocolToolToOperation(toolName) != null;
|
|
21
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* The operation a protocol tool performs, or a throw. Called at module load by
|
|
45
|
+
* the tool definitions, so a verb defined without a registry entry takes the
|
|
46
|
+
* import down instead of shipping half-registered.
|
|
47
|
+
*/
|
|
48
|
+
export function requireProtocolOperation(toolName) {
|
|
49
|
+
const operation = mapProtocolToolToOperation(toolName);
|
|
50
|
+
if (!operation) {
|
|
51
|
+
throw new Error(`Unknown protocol tool "${toolName}": add it to PROTOCOL_TOOL_TO_OPERATION ` +
|
|
52
|
+
'(agent-sdk/src/tasks/protocolRegistry.ts). Every consumer of the protocol ' +
|
|
53
|
+
'set reads that table — a tool missing from it dispatches but is never ' +
|
|
54
|
+
'recognised as protocol, so its result never reaches context.');
|
|
55
|
+
}
|
|
56
|
+
return operation;
|
|
57
|
+
}
|
|
@@ -10,12 +10,23 @@ export function toolFromCapability(cap) {
|
|
|
10
10
|
return defineTool(cap.names.sdk, cap.params, async (args, ctx) => {
|
|
11
11
|
const operatorKey = ctx?.['operatorKey'];
|
|
12
12
|
const agentId = ctx?.['agentId'];
|
|
13
|
+
// ZIG-1092 — the lane was already threaded to this exact point by
|
|
14
|
+
// ZIG-775's shared executor (`chatId: effect.sessionId` in
|
|
15
|
+
// executeToolWithSideEffects); it just was not used to narrow anything.
|
|
16
|
+
// Reading it here makes the fence brain-agnostic by construction: the
|
|
17
|
+
// Claude brain and the FSM brain both arrive through this adapter.
|
|
18
|
+
const laneId = ctx?.['chatId'] ||
|
|
19
|
+
ctx?.['sessionId'];
|
|
13
20
|
if (!operatorKey)
|
|
14
21
|
throw new Error('operatorKey missing from tool context');
|
|
15
22
|
if (cap.needsAgentId && !agentId)
|
|
16
23
|
throw new Error('agentId missing from tool context');
|
|
17
24
|
const env = {
|
|
18
|
-
creds: {
|
|
25
|
+
creds: {
|
|
26
|
+
operatorKey,
|
|
27
|
+
...(agentId ? { agentId } : {}),
|
|
28
|
+
...(laneId ? { laneId } : {}),
|
|
29
|
+
},
|
|
19
30
|
// BACKEND_URL / ZIGGS_BACKEND_URL are the SDK runner's overrides; the
|
|
20
31
|
// shared clients default to HTTP_URL (api-client convention) otherwise.
|
|
21
32
|
baseUrl: process.env.BACKEND_URL || process.env.ZIGGS_BACKEND_URL || undefined,
|
|
@@ -12,10 +12,14 @@ export async function recordReport(input) {
|
|
|
12
12
|
const { creds, taskId, report, chatId, agreementId, deliverable } = input;
|
|
13
13
|
// ZIG-1037: no container needed. The deliverable is bound to its task, which
|
|
14
14
|
// is what makes it retrievable as `via=task:<taskId>` and readable by the
|
|
15
|
-
// task's parties — a chat or agreement was never what made that work.
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
15
|
+
// task's parties — a chat or agreement was never what made that work.
|
|
16
|
+
//
|
|
17
|
+
// ZIG-1075: the agreement still wins here, and this is now the ONE place that
|
|
18
|
+
// resolves rather than refuses — deliberately, because the caller is not a
|
|
19
|
+
// model choosing arguments. `recordReport` is handed both ids by the runtime
|
|
20
|
+
// (the task's agreement and the chat it is running in), so there is no
|
|
21
|
+
// mistake to surface and nobody to retry: refusing would fail a finished
|
|
22
|
+
// task's delivery over a pair of ids it did not choose.
|
|
19
23
|
const scope = agreementId ? { agreementId } : chatId ? { chatId } : {};
|
|
20
24
|
// Deliverable FIRST, then close the task.
|
|
21
25
|
//
|
package/dist/types.d.ts
CHANGED
|
@@ -187,15 +187,6 @@ export interface Ctx {
|
|
|
187
187
|
/** Focus agreement — sync'd with tasks[activeTaskId] when a task is focused. */
|
|
188
188
|
activeAgreementId: string | null;
|
|
189
189
|
pendingProposalAgreementId: string | null;
|
|
190
|
-
/**
|
|
191
|
-
* @deprecated ZIG-1057 — unused mirrors; always `{}` / `[]` after bootstrap.
|
|
192
|
-
* Read via `allPendingSubtasks` / `allCollectedResults` / `allDelegatedAgreementIds`.
|
|
193
|
-
*/
|
|
194
|
-
pendingSubtasks: Record<string, PendingSubtask>;
|
|
195
|
-
/** @deprecated ZIG-1057 — see `pendingSubtasks`. */
|
|
196
|
-
collectedResults: CollectedSubtaskResult[];
|
|
197
|
-
/** @deprecated ZIG-1057 — see `pendingSubtasks`. */
|
|
198
|
-
delegatedAgreementIds: string[];
|
|
199
190
|
toolResults: ToolResultEntry[];
|
|
200
191
|
lastOutcome: Outcome;
|
|
201
192
|
/** Claude Agent SDK session id for resume across ticks (claude-sdk cognition only). */
|
|
@@ -385,8 +376,15 @@ export interface RecordedEntry {
|
|
|
385
376
|
export interface WakeWriteKeys {
|
|
386
377
|
/** Stable id of this wake, or null when its trigger has no durable delivery identity (timer/synthetic frames) — null means writes go keyless, as before. */
|
|
387
378
|
readonly wakeKey: string | null;
|
|
388
|
-
/**
|
|
389
|
-
|
|
379
|
+
/**
|
|
380
|
+
* Key for an artifact write of this wake. Pass the artifact's content so the
|
|
381
|
+
* key is derived from it (ZIG-1045 semantics): a wake writing several
|
|
382
|
+
* artifacts gets several keys, and a redelivered wake that re-writes the same
|
|
383
|
+
* content reuses its key instead of colliding on an ordinal slot and being
|
|
384
|
+
* discarded. Omitting content falls back to a per-wake ordinal — call exactly
|
|
385
|
+
* once per write in that case.
|
|
386
|
+
*/
|
|
387
|
+
artifactKey(content?: unknown): string | undefined;
|
|
390
388
|
/** Key for a task state transition. Derived from (taskId, state), so it is stable regardless of tool-call order within the wake. */
|
|
391
389
|
taskStateKey(taskId: string, state: string): string | undefined;
|
|
392
390
|
/**
|
|
@@ -512,7 +510,19 @@ export interface ServicesConfig {
|
|
|
512
510
|
}
|
|
513
511
|
export interface DefineAgentInput {
|
|
514
512
|
agentId: string;
|
|
513
|
+
/**
|
|
514
|
+
* Human-facing one-liner: what this agent does, for a buyer reading the store
|
|
515
|
+
* row. NOT the system prompt — a claude-sdk agent puts its prompt in
|
|
516
|
+
* `systemPrompt` (ZIG-1080: every claude-sdk agent used to pass its prompt
|
|
517
|
+
* here, and `deriveAgentRecord` published it straight to the store).
|
|
518
|
+
*/
|
|
515
519
|
description: string;
|
|
520
|
+
/**
|
|
521
|
+
* Claude-brain only: the agent's system prompt (its `## Your role` block).
|
|
522
|
+
* Falls back to `description` when absent, which is what every FSM agent
|
|
523
|
+
* relies on. Never surfaced in the store.
|
|
524
|
+
*/
|
|
525
|
+
systemPrompt?: string;
|
|
516
526
|
specialization?: string;
|
|
517
527
|
initial: string;
|
|
518
528
|
states: Record<string, State>;
|
|
@@ -534,7 +544,10 @@ export interface AgentConfig {
|
|
|
534
544
|
anthropicKey: string | undefined;
|
|
535
545
|
operatorKey?: string;
|
|
536
546
|
agentId: string;
|
|
547
|
+
/** Store-facing one-liner. See DefineAgentInput.description. */
|
|
537
548
|
description: string;
|
|
549
|
+
/** Claude-brain system prompt; falls back to `description`. */
|
|
550
|
+
systemPrompt?: string;
|
|
538
551
|
specialization?: string;
|
|
539
552
|
tools: unknown[];
|
|
540
553
|
model: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ziggs-ai/agent-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Agent framework SDK for building autonomous agents on the Ziggs platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@anthropic-ai/claude-agent-sdk": "^0.3.204",
|
|
27
27
|
"@anthropic-ai/sdk": "^0.110.0",
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
29
|
-
"@ziggs-ai/api-client": "^0.
|
|
29
|
+
"@ziggs-ai/api-client": "^0.9.1",
|
|
30
30
|
"ajv": "^8.17.1",
|
|
31
31
|
"dotenv": "^17.2.3",
|
|
32
32
|
"openai": "^4.0.0",
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Launcher startup backlog policy (ZIG-910).
|
|
3
|
-
*
|
|
4
|
-
* "Work waits" has no statute of limitations on the backend — the inbox never
|
|
5
|
-
* expires anything. But when a fleet comes up after a dark period, replaying
|
|
6
|
-
* every weeks-old actionable slice means a mass of LLM turns on stale work at
|
|
7
|
-
* real token cost. The consumer chooses what to pick up: this gate keeps the
|
|
8
|
-
* operator-poll lazy start from resurrecting agents whose newest actionable
|
|
9
|
-
* item is older than a cutoff. Skips are never silent — one loud aggregated
|
|
10
|
-
* summary line announces what was left down and how to opt back in.
|
|
11
|
-
*
|
|
12
|
-
* Env:
|
|
13
|
-
* FLEET_REPLAY_MAX_AGE cutoff, e.g. "3d" | "12h" | "30m" | bare number =
|
|
14
|
-
* days (default "3d")
|
|
15
|
-
* FLEET_REPLAY_STALE=1 replay everything (disable the gate)
|
|
16
|
-
*
|
|
17
|
-
* Scope is the launcher only. The backend inbox is untouched, explicit
|
|
18
|
-
* `pool.startAgent()` / preStart are untouched, and an agent with ANY fresh item
|
|
19
|
-
* starts normally (its own inbox loop then drains the stale backlog too).
|
|
20
|
-
*/
|
|
21
|
-
import type { InboxOperatorAgentEntry } from '@ziggs-ai/api-client';
|
|
22
|
-
export declare const DEFAULT_REPLAY_MAX_AGE_MS: number;
|
|
23
|
-
export interface ReplayPolicy {
|
|
24
|
-
/** Skip slices whose newest actionable item is older than this. */
|
|
25
|
-
maxAgeMs: number;
|
|
26
|
-
/** FLEET_REPLAY_STALE=1 — replay everything, gate disabled. */
|
|
27
|
-
replayAll: boolean;
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* "3d" | "12h" | "30m" | "45s"; a bare number means days (the knob's natural
|
|
31
|
-
* granularity). Returns null on garbage so the caller can warn and fall back.
|
|
32
|
-
*/
|
|
33
|
-
export declare function parseReplayMaxAge(raw: string): number | null;
|
|
34
|
-
export declare function resolveReplayPolicy(env?: NodeJS.ProcessEnv): ReplayPolicy;
|
|
35
|
-
/**
|
|
36
|
-
* Newest actionable instant for an agent, from its operator-sweep entry.
|
|
37
|
-
*
|
|
38
|
-
* The sweep hands this over directly now — it is the agent's delivery
|
|
39
|
-
* watermark — where this used to fold together the max timestamp across every
|
|
40
|
-
* channel of a whole envelope. An agent surfaced only for open work carries no
|
|
41
|
-
* delivery instant: age is unprovable, so it is treated as fresh and started.
|
|
42
|
-
* Stale-skipping a half-finished task would strand it.
|
|
43
|
-
*/
|
|
44
|
-
export declare function newestActionableAt(entry: InboxOperatorAgentEntry): number | null;
|
|
45
|
-
export declare function formatAge(ms: number): string;
|
|
46
|
-
export interface StaleBacklogGateOptions extends ReplayPolicy {
|
|
47
|
-
label?: string;
|
|
48
|
-
now?: () => number;
|
|
49
|
-
log?: (line: string) => void;
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Build the `shouldLazyStart` gate for the operator poll. Skipped agents
|
|
53
|
-
* resurface in every envelope (reading never acks), so announcements are
|
|
54
|
-
* per-agent-once: each poll pass that skips agents not yet announced emits one
|
|
55
|
-
* aggregated warn line covering them.
|
|
56
|
-
*/
|
|
57
|
-
export declare function createStaleBacklogGate({ maxAgeMs, replayAll, label, now, log, }: StaleBacklogGateOptions): (entry: InboxOperatorAgentEntry) => boolean;
|
|
@@ -1,100 +0,0 @@
|
|
|
1
|
-
import { runtimeLog } from '../shared/runtimeLog.js';
|
|
2
|
-
export const DEFAULT_REPLAY_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
|
|
3
|
-
/**
|
|
4
|
-
* "3d" | "12h" | "30m" | "45s"; a bare number means days (the knob's natural
|
|
5
|
-
* granularity). Returns null on garbage so the caller can warn and fall back.
|
|
6
|
-
*/
|
|
7
|
-
export function parseReplayMaxAge(raw) {
|
|
8
|
-
const m = /^\s*(\d+(?:\.\d+)?)\s*([dhms]?)\s*$/i.exec(raw);
|
|
9
|
-
if (!m)
|
|
10
|
-
return null;
|
|
11
|
-
const value = Number(m[1]);
|
|
12
|
-
if (!Number.isFinite(value) || value <= 0)
|
|
13
|
-
return null;
|
|
14
|
-
const unitMs = { d: 86_400_000, h: 3_600_000, m: 60_000, s: 1_000 }[(m[2] || 'd').toLowerCase()];
|
|
15
|
-
return value * unitMs;
|
|
16
|
-
}
|
|
17
|
-
export function resolveReplayPolicy(env = process.env) {
|
|
18
|
-
const replayAll = env.FLEET_REPLAY_STALE === '1' || env.FLEET_REPLAY_STALE === 'true';
|
|
19
|
-
const raw = env.FLEET_REPLAY_MAX_AGE?.trim();
|
|
20
|
-
let maxAgeMs = DEFAULT_REPLAY_MAX_AGE_MS;
|
|
21
|
-
if (raw) {
|
|
22
|
-
const parsed = parseReplayMaxAge(raw);
|
|
23
|
-
if (parsed == null) {
|
|
24
|
-
runtimeLog.warn('staleBacklogGate', `FLEET_REPLAY_MAX_AGE="${raw}" is not a duration (use e.g. "3d", "12h", "30m") — using default ${formatAge(DEFAULT_REPLAY_MAX_AGE_MS)}`);
|
|
25
|
-
}
|
|
26
|
-
else {
|
|
27
|
-
maxAgeMs = parsed;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
return { maxAgeMs, replayAll };
|
|
31
|
-
}
|
|
32
|
-
/**
|
|
33
|
-
* Newest actionable instant for an agent, from its operator-sweep entry.
|
|
34
|
-
*
|
|
35
|
-
* The sweep hands this over directly now — it is the agent's delivery
|
|
36
|
-
* watermark — where this used to fold together the max timestamp across every
|
|
37
|
-
* channel of a whole envelope. An agent surfaced only for open work carries no
|
|
38
|
-
* delivery instant: age is unprovable, so it is treated as fresh and started.
|
|
39
|
-
* Stale-skipping a half-finished task would strand it.
|
|
40
|
-
*/
|
|
41
|
-
export function newestActionableAt(entry) {
|
|
42
|
-
if (!entry.deliveredUpTo)
|
|
43
|
-
return null;
|
|
44
|
-
const t = Date.parse(entry.deliveredUpTo);
|
|
45
|
-
return Number.isNaN(t) ? null : t;
|
|
46
|
-
}
|
|
47
|
-
export function formatAge(ms) {
|
|
48
|
-
if (ms >= 86_400_000)
|
|
49
|
-
return `${trimNum(ms / 86_400_000)}d`;
|
|
50
|
-
if (ms >= 3_600_000)
|
|
51
|
-
return `${trimNum(ms / 3_600_000)}h`;
|
|
52
|
-
if (ms >= 60_000)
|
|
53
|
-
return `${trimNum(ms / 60_000)}m`;
|
|
54
|
-
return `${trimNum(ms / 1_000)}s`;
|
|
55
|
-
}
|
|
56
|
-
function trimNum(n) {
|
|
57
|
-
return Number(n.toFixed(1)).toString();
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Build the `shouldLazyStart` gate for the operator poll. Skipped agents
|
|
61
|
-
* resurface in every envelope (reading never acks), so announcements are
|
|
62
|
-
* per-agent-once: each poll pass that skips agents not yet announced emits one
|
|
63
|
-
* aggregated warn line covering them.
|
|
64
|
-
*/
|
|
65
|
-
export function createStaleBacklogGate({ maxAgeMs, replayAll, label = 'launcher', now = Date.now, log, }) {
|
|
66
|
-
const emit = log ?? ((line) => runtimeLog.warn(label, line));
|
|
67
|
-
const announced = new Set();
|
|
68
|
-
let pendingAgents = [];
|
|
69
|
-
let flushScheduled = false;
|
|
70
|
-
// One envelope's entries are gated synchronously; a microtask-deferred
|
|
71
|
-
// flush turns a whole boot flood into a single summary line.
|
|
72
|
-
const flush = () => {
|
|
73
|
-
flushScheduled = false;
|
|
74
|
-
if (pendingAgents.length === 0)
|
|
75
|
-
return;
|
|
76
|
-
const preview = pendingAgents.slice(0, 5).join(', ');
|
|
77
|
-
const more = pendingAgents.length > 5 ? `, +${pendingAgents.length - 5} more` : '';
|
|
78
|
-
emit(`startup backlog policy: skipped ${pendingAgents.length} agent(s) whose newest mail is older than ${formatAge(maxAgeMs)} (${preview}${more}) — set FLEET_REPLAY_STALE=1 to replay them`);
|
|
79
|
-
pendingAgents = [];
|
|
80
|
-
};
|
|
81
|
-
return (entry) => {
|
|
82
|
-
if (replayAll)
|
|
83
|
-
return true;
|
|
84
|
-
// Open work always starts, however old: the task is unfinished, not stale.
|
|
85
|
-
if (entry.hasOpenTasks)
|
|
86
|
-
return true;
|
|
87
|
-
const newest = newestActionableAt(entry);
|
|
88
|
-
if (newest == null || now() - newest <= maxAgeMs)
|
|
89
|
-
return true;
|
|
90
|
-
if (!announced.has(entry.agentId)) {
|
|
91
|
-
announced.add(entry.agentId);
|
|
92
|
-
pendingAgents.push(entry.agentId);
|
|
93
|
-
if (!flushScheduled) {
|
|
94
|
-
flushScheduled = true;
|
|
95
|
-
queueMicrotask(flush);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return false;
|
|
99
|
-
};
|
|
100
|
-
}
|