agent-tempo 1.5.0 → 1.6.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/dashboard/package.json +1 -1
- package/dist/activities/outbox.d.ts +14 -1
- package/dist/activities/outbox.js +41 -0
- package/dist/cli/commands.js +11 -0
- package/dist/cli/config-command.d.ts +15 -0
- package/dist/cli/config-command.js +22 -7
- package/dist/client/core.js +15 -0
- package/dist/client/interface.d.ts +9 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +9 -0
- package/dist/http/server.js +12 -1
- package/dist/pi/cue-pump.d.ts +86 -13
- package/dist/pi/cue-pump.js +102 -15
- package/dist/pi/extension.d.ts +29 -15
- package/dist/pi/extension.js +96 -19
- package/dist/pi/index.d.ts +2 -2
- package/dist/pi/index.js +2 -1
- package/dist/pi/pi-types.d.ts +50 -0
- package/dist/pi/reset-pump.d.ts +55 -17
- package/dist/pi/reset-pump.js +70 -20
- package/dist/server-tools.d.ts +7 -1
- package/dist/server-tools.js +2 -2
- package/dist/server.js +5 -2
- package/dist/spawn.d.ts +10 -0
- package/dist/spawn.js +7 -0
- package/dist/tools/recruit.d.ts +19 -2
- package/dist/tools/recruit.js +26 -2
- package/dist/tui/index.js +1 -0
- package/dist/utils/parent-death-watchdog.d.ts +12 -0
- package/dist/utils/parent-death-watchdog.js +25 -0
- package/package.json +1 -1
package/dashboard/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Client } from '@temporalio/client';
|
|
2
2
|
import { Config } from '../config';
|
|
3
|
-
import { AgentType, MockMode, DetachReason } from '../types';
|
|
3
|
+
import { AgentType, AttachmentPhase, MockMode, DetachReason } from '../types';
|
|
4
4
|
import type { ClaudeCodeHeadlessPermissionMode } from '../adapters/claude-code-headless/types';
|
|
5
5
|
import type { IngestTokenRegistry } from '../http/ingest-registry';
|
|
6
6
|
import type { GateRegistry } from '../http/gate-registry';
|
|
@@ -180,6 +180,12 @@ export interface SpawnProcessInput {
|
|
|
180
180
|
export interface OutboxActivityResult {
|
|
181
181
|
success: boolean;
|
|
182
182
|
error?: string;
|
|
183
|
+
/**
|
|
184
|
+
* Human-readable note for a non-failure outcome the caller may surface — e.g.
|
|
185
|
+
* #676 FIX-3's "skipped duplicate spawn" no-op. Floor today is the daemon log;
|
|
186
|
+
* structured here so a future workflow-side relay can surface it to the operator.
|
|
187
|
+
*/
|
|
188
|
+
note?: string;
|
|
183
189
|
}
|
|
184
190
|
export interface RecruitResult extends OutboxActivityResult {
|
|
185
191
|
/** Session UUID assigned at recruit time. */
|
|
@@ -214,4 +220,11 @@ export interface OutboxActivities {
|
|
|
214
220
|
* destroy path REVOKES it. Optional: undefined disables ingest-token minting
|
|
215
221
|
* (e.g. the dev test harness that constructs activities without the daemon).
|
|
216
222
|
*/
|
|
223
|
+
/**
|
|
224
|
+
* #676 FIX-3 — should spawnProcess SKIP as a duplicate dispatch? TRUE iff this is
|
|
225
|
+
* a FRESH recruit (no `attachmentId` handoff) AND a live adapter is already
|
|
226
|
+
* attached. A restart/migrate carries `attachmentId` (the handoff to its fresh
|
|
227
|
+
* claim — phase is legitimately live) → never skipped. Pure + exported for tests.
|
|
228
|
+
*/
|
|
229
|
+
export declare function shouldSkipDuplicateSpawn(attachmentId: string | undefined, phase: AttachmentPhase): boolean;
|
|
217
230
|
export declare function createOutboxActivities(client: Client, config: Config, ingestTokens?: IngestTokenRegistry, gate?: GateRegistry): OutboxActivities;
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.shouldSkipDuplicateSpawn = shouldSkipDuplicateSpawn;
|
|
36
37
|
exports.createOutboxActivities = createOutboxActivities;
|
|
37
38
|
const client_1 = require("@temporalio/client");
|
|
38
39
|
const activity_1 = require("@temporalio/activity");
|
|
@@ -144,6 +145,17 @@ function classifyAndRethrow(err, contextPrefix) {
|
|
|
144
145
|
* destroy path REVOKES it. Optional: undefined disables ingest-token minting
|
|
145
146
|
* (e.g. the dev test harness that constructs activities without the daemon).
|
|
146
147
|
*/
|
|
148
|
+
/**
|
|
149
|
+
* #676 FIX-3 — should spawnProcess SKIP as a duplicate dispatch? TRUE iff this is
|
|
150
|
+
* a FRESH recruit (no `attachmentId` handoff) AND a live adapter is already
|
|
151
|
+
* attached. A restart/migrate carries `attachmentId` (the handoff to its fresh
|
|
152
|
+
* claim — phase is legitimately live) → never skipped. Pure + exported for tests.
|
|
153
|
+
*/
|
|
154
|
+
function shouldSkipDuplicateSpawn(attachmentId, phase) {
|
|
155
|
+
if (attachmentId)
|
|
156
|
+
return false; // restart/migrate handoff — must spawn
|
|
157
|
+
return phase === 'attached' || phase === 'processing' || phase === 'awaiting';
|
|
158
|
+
}
|
|
147
159
|
function createOutboxActivities(client, config, ingestTokens, gate) {
|
|
148
160
|
return {
|
|
149
161
|
async deliverCue(input) {
|
|
@@ -315,6 +327,35 @@ function createOutboxActivities(client, config, ingestTokens, gate) {
|
|
|
315
327
|
const { targetName, workDir, isConductor, agent, systemPrompt, ensemble, temporalAddress, temporalNamespace, agentDefinition, agentDefinitionPath, nativeResolvable, resume, sessionId, allowedTools, claudeBin, attachmentId, attachmentRunId, adapterId, mockMode, mockScenario, model, permissionMode, dangerouslySkipPermissions, toolAccess } = input;
|
|
316
328
|
// Read secrets from the worker's config closure — never from workflow state
|
|
317
329
|
const { temporalApiKey, temporalTlsCertPath, temporalTlsKeyPath } = config;
|
|
330
|
+
// #676 FIX-3 — double-dispatch backstop (ACTIVITY-level; no workflow/bundle
|
|
331
|
+
// touch). A FRESH recruit (NO attachmentId) of a name that ALREADY has a live
|
|
332
|
+
// adapter is a duplicate dispatch → skip the spawn so we don't race a second
|
|
333
|
+
// adapter for the lease. attachmentId PRESENT = a restart/migrate HANDOFF to a
|
|
334
|
+
// fresh claim (phase is legitimately {attached|processing|awaiting} from that
|
|
335
|
+
// claim) → MUST NOT skip, or restart attaches to a non-existent adapter.
|
|
336
|
+
// Guard ABOVE the agent switch so it covers every agent. TOCTOU best-effort —
|
|
337
|
+
// claimAttachment's expectedAttachmentId arbitrates the rare race.
|
|
338
|
+
if (!attachmentId) {
|
|
339
|
+
const checkWorkflowId = isConductor ? (0, config_1.conductorWorkflowId)(ensemble) : (0, config_1.sessionWorkflowId)(ensemble, targetName);
|
|
340
|
+
try {
|
|
341
|
+
const info = await client.workflow.getHandle(checkWorkflowId).query(signals_1.attachmentInfoQuery);
|
|
342
|
+
if (shouldSkipDuplicateSpawn(attachmentId, info.phase)) {
|
|
343
|
+
// Corrected message (architect): force does NOT bypass this skip under
|
|
344
|
+
// FIX-3(a), so do NOT tell the operator to pass force. Replace-a-live-
|
|
345
|
+
// adapter is restart/migrate's lane; a stale session self-heals in ~90s.
|
|
346
|
+
const note = `recruit skipped: player "${targetName}" is already attached (phase=${info.phase}) — ` +
|
|
347
|
+
`spawning now would create a duplicate adapter racing the live session. To replace it, ` +
|
|
348
|
+
`use \`restart\` (same host) or \`migrate\` (other host). If the session is actually stale, ` +
|
|
349
|
+
`its lease expires and it's reaped within ~90s, after which recruit spawns normally.`;
|
|
350
|
+
log(`[#676 FIX-3] ${note}`);
|
|
351
|
+
return { success: true, note };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
// Not-queryable-yet / transient → fall through to spawn (best-effort guard).
|
|
356
|
+
log(`FIX-3 attachment pre-check inconclusive for "${targetName}" — proceeding to spawn: ${err instanceof Error ? err.message : String(err)}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
318
359
|
try {
|
|
319
360
|
if (agent === 'mock') {
|
|
320
361
|
// ADR 0014 PR-2 — mock adapter spawns headless. No terminal,
|
package/dist/cli/commands.js
CHANGED
|
@@ -345,6 +345,10 @@ async function applyLineupPlayersAndSchedules(args) {
|
|
|
345
345
|
temporalTlsKeyPath: config.temporalTlsKeyPath,
|
|
346
346
|
isConductor: false,
|
|
347
347
|
workDir: playerWorkDir,
|
|
348
|
+
// #672 — a `up --lineup` copilot PLAYER is also spawned directly (no
|
|
349
|
+
// terminal) by the transient CLI → same self-kill bug as the conductor.
|
|
350
|
+
// Skip the ppid-poll; daemon-recruit copilot (outbox.ts) keeps it.
|
|
351
|
+
transientSpawner: true,
|
|
348
352
|
});
|
|
349
353
|
}
|
|
350
354
|
else {
|
|
@@ -589,6 +593,9 @@ async function start(opts) {
|
|
|
589
593
|
temporalTlsKeyPath: config.temporalTlsKeyPath,
|
|
590
594
|
isConductor: opts.conductor,
|
|
591
595
|
workDir,
|
|
596
|
+
// #672 — CLI-direct copilot spawn (start path): transient `up`/`conduct`
|
|
597
|
+
// spawner → skip the ppid-poll. Daemon-recruit copilot (outbox.ts) omits it.
|
|
598
|
+
transientSpawner: true,
|
|
592
599
|
});
|
|
593
600
|
out.success(`Launched copilot bridge "${sessionName}" (pid ${pid ?? 'unknown'})`);
|
|
594
601
|
}
|
|
@@ -1304,6 +1311,10 @@ async function up(opts) {
|
|
|
1304
1311
|
temporalTlsKeyPath: config.temporalTlsKeyPath,
|
|
1305
1312
|
isConductor: true,
|
|
1306
1313
|
workDir: process.cwd(),
|
|
1314
|
+
// #672 — the `up` CLI is a TRANSIENT spawner; the detached bridge must NOT
|
|
1315
|
+
// ppid-poll it (would self-kill seconds after launch → lease never renews →
|
|
1316
|
+
// all players detach). The daemon-recruit path (outbox.ts) omits this.
|
|
1317
|
+
transientSpawner: true,
|
|
1307
1318
|
}));
|
|
1308
1319
|
}
|
|
1309
1320
|
else if (conductorAgent === 'pi') {
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
import type { AgentType } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Agents valid as a persistent `defaultAgent` — the conductor-capable PRODUCTION
|
|
4
|
+
* agents. `defaultAgent` drives the conductor that `up` / `start` / `conduct`
|
|
5
|
+
* spawn when no `--agent` is given (`cli.ts` `resolvedAgent`), and the
|
|
6
|
+
* conductor-spawn branch only realises `copilot` / `pi` / else→`claude`. So:
|
|
7
|
+
* - `mock` is DEV-ONLY (recruit pre-flight rejects it outside dev mode) — never
|
|
8
|
+
* a persistent default.
|
|
9
|
+
* - the headless adapters (`claude-api` / `opencode` / `claude-code-headless`)
|
|
10
|
+
* can't be a conductor — they'd silently fall through to `claude` — so they
|
|
11
|
+
* are not offered here.
|
|
12
|
+
* Single source of truth for the interactive selector + `config set` validation
|
|
13
|
+
* (#666 — adds `pi` so the new interactive Pi conductor can be the default).
|
|
14
|
+
*/
|
|
15
|
+
export declare const VALID_DEFAULT_AGENTS: readonly AgentType[];
|
|
1
16
|
/** Interactive config setup: `agent-tempo config` */
|
|
2
17
|
export declare function configInteractive(): Promise<void>;
|
|
3
18
|
/** Non-interactive: `agent-tempo config set <key> <value>` */
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.VALID_DEFAULT_AGENTS = void 0;
|
|
36
37
|
exports.configInteractive = configInteractive;
|
|
37
38
|
exports.configSet = configSet;
|
|
38
39
|
exports.configShow = configShow;
|
|
@@ -41,6 +42,20 @@ const readline = __importStar(require("readline"));
|
|
|
41
42
|
const config_1 = require("../config");
|
|
42
43
|
const config_2 = require("../config");
|
|
43
44
|
const out = __importStar(require("./output"));
|
|
45
|
+
/**
|
|
46
|
+
* Agents valid as a persistent `defaultAgent` — the conductor-capable PRODUCTION
|
|
47
|
+
* agents. `defaultAgent` drives the conductor that `up` / `start` / `conduct`
|
|
48
|
+
* spawn when no `--agent` is given (`cli.ts` `resolvedAgent`), and the
|
|
49
|
+
* conductor-spawn branch only realises `copilot` / `pi` / else→`claude`. So:
|
|
50
|
+
* - `mock` is DEV-ONLY (recruit pre-flight rejects it outside dev mode) — never
|
|
51
|
+
* a persistent default.
|
|
52
|
+
* - the headless adapters (`claude-api` / `opencode` / `claude-code-headless`)
|
|
53
|
+
* can't be a conductor — they'd silently fall through to `claude` — so they
|
|
54
|
+
* are not offered here.
|
|
55
|
+
* Single source of truth for the interactive selector + `config set` validation
|
|
56
|
+
* (#666 — adds `pi` so the new interactive Pi conductor can be the default).
|
|
57
|
+
*/
|
|
58
|
+
exports.VALID_DEFAULT_AGENTS = ['claude', 'copilot', 'pi'];
|
|
44
59
|
// NOTE: `createTemporalConnection` is dynamic-imported inside `configInteractive`'s
|
|
45
60
|
// connection-test step (issue #157 PR C). Top-level static import would pull in
|
|
46
61
|
// `@temporalio/client`, defeating the crash-proof property of `config show` /
|
|
@@ -131,11 +146,11 @@ async function configInteractive() {
|
|
|
131
146
|
config.temporalTlsKeyPath = await ask('TLS key path', existing.temporalTlsKeyPath);
|
|
132
147
|
}
|
|
133
148
|
// Default agent type
|
|
134
|
-
const agentChoice = await choose('Default agent', [
|
|
135
|
-
if (agentChoice
|
|
136
|
-
config.defaultAgent =
|
|
149
|
+
const agentChoice = await choose('Default agent', [...exports.VALID_DEFAULT_AGENTS]);
|
|
150
|
+
if (agentChoice !== 'claude') {
|
|
151
|
+
config.defaultAgent = agentChoice;
|
|
137
152
|
}
|
|
138
|
-
// Don't set defaultAgent if claude — it's the default, keeps config clean
|
|
153
|
+
// Don't set defaultAgent if claude — it's the implicit default, keeps config clean
|
|
139
154
|
(0, config_1.saveConfigFile)(config);
|
|
140
155
|
out.success(`Saved to ${config_1.CONFIG_FILE_PATH}`);
|
|
141
156
|
// Test connection
|
|
@@ -190,9 +205,9 @@ function configSet(key, value) {
|
|
|
190
205
|
out.log(` Valid keys: ${Object.keys(keyMap).join(', ')}`);
|
|
191
206
|
process.exit(1);
|
|
192
207
|
}
|
|
193
|
-
// Validate agent type
|
|
194
|
-
if (configKey === 'defaultAgent' && value
|
|
195
|
-
out.error(`Invalid agent type: "${value}". Must be
|
|
208
|
+
// Validate agent type — restrict to the conductor-capable production agents.
|
|
209
|
+
if (configKey === 'defaultAgent' && !exports.VALID_DEFAULT_AGENTS.includes(value)) {
|
|
210
|
+
out.error(`Invalid agent type: "${value}". Must be one of: ${exports.VALID_DEFAULT_AGENTS.join(', ')}.`);
|
|
196
211
|
process.exit(1);
|
|
197
212
|
}
|
|
198
213
|
config[configKey] = value;
|
package/dist/client/core.js
CHANGED
|
@@ -1167,6 +1167,21 @@ function createTempoClientCore(client, opts = {}) {
|
|
|
1167
1167
|
return false;
|
|
1168
1168
|
}
|
|
1169
1169
|
},
|
|
1170
|
+
async ensembleExists(ensemble) {
|
|
1171
|
+
// #673 — STRONGLY-CONSISTENT existence check. `describe()` the per-ensemble
|
|
1172
|
+
// maestro HUB (started at `up`/creation via `ensureMaestroWorkflow`) — it
|
|
1173
|
+
// reflects a just-started workflow IMMEDIATELY, unlike `listEnsembles`
|
|
1174
|
+
// (Temporal visibility, eventually consistent on Cloud). Only RUNNING
|
|
1175
|
+
// counts as "exists": a TERMINATED/COMPLETED hub (destroyed ensemble) → false,
|
|
1176
|
+
// and a never-created hub throws WorkflowNotFoundError → false.
|
|
1177
|
+
try {
|
|
1178
|
+
const desc = await handle((0, config_1.maestroWorkflowId)(ensemble)).describe();
|
|
1179
|
+
return desc.status.name === 'RUNNING';
|
|
1180
|
+
}
|
|
1181
|
+
catch {
|
|
1182
|
+
return false;
|
|
1183
|
+
}
|
|
1184
|
+
},
|
|
1170
1185
|
// ── Maestro session (TUI-owned workflow for two-way messaging) ──
|
|
1171
1186
|
async ensureMaestroSession(ensemble) {
|
|
1172
1187
|
const workflowId = (0, config_1.sessionWorkflowId)(ensemble, 'maestro');
|
|
@@ -428,6 +428,15 @@ export interface TempoClientCore {
|
|
|
428
428
|
isConnected(): Promise<boolean>;
|
|
429
429
|
/** Check if the Global Maestro workflow is running. */
|
|
430
430
|
hasGlobalMaestro(): Promise<boolean>;
|
|
431
|
+
/**
|
|
432
|
+
* #673 — STRONGLY-CONSISTENT existence check for an ensemble: `describe()` the
|
|
433
|
+
* per-ensemble maestro hub workflow (started at `up`/creation) and report
|
|
434
|
+
* whether it's RUNNING. Unlike {@link listEnsembles} (Temporal VISIBILITY,
|
|
435
|
+
* eventually consistent on Cloud), `describe` reflects a just-started workflow
|
|
436
|
+
* immediately — the SSE existence gate uses it as a fallback so a fresh
|
|
437
|
+
* ensemble isn't 404'd before visibility catches up.
|
|
438
|
+
*/
|
|
439
|
+
ensembleExists(ensemble: string): Promise<boolean>;
|
|
431
440
|
/**
|
|
432
441
|
* Subscribe to the per-ensemble SSE event stream exposed by the daemon
|
|
433
442
|
* at `/v1/events/:ensemble`. Returns an `AsyncIterable<TempoEvent>` —
|
package/dist/config.d.ts
CHANGED
|
@@ -121,6 +121,15 @@ export declare const ENV: {
|
|
|
121
121
|
* module loads (see `src/cli/dev-mode-bootstrap.ts`).
|
|
122
122
|
*/
|
|
123
123
|
readonly DEV_MODE: "AGENT_TEMPO_DEV_MODE";
|
|
124
|
+
/**
|
|
125
|
+
* #672 — set to `'1'` by a TRANSIENT-CLI spawner (e.g. the short-lived `up`
|
|
126
|
+
* conductor) on a process it intentionally DETACHES to outlive that spawner.
|
|
127
|
+
* Tells the parent-death watchdog to skip ONLY the ppid-poll signal (which
|
|
128
|
+
* would otherwise self-kill the detached process when the transient spawner
|
|
129
|
+
* exits); the universally-correct stdin-EOF signal stays. Daemon-recruit
|
|
130
|
+
* spawns do NOT set it, so recruited adapters keep the #604 anti-leak ppid-poll.
|
|
131
|
+
*/
|
|
132
|
+
readonly NO_PPID_WATCHDOG: "AGENT_TEMPO_NO_PPID_WATCHDOG";
|
|
124
133
|
/**
|
|
125
134
|
* Escape hatch for triple-isolated environments (ADR 0014 §5.3). When
|
|
126
135
|
* set, `resolveTempoHome()` returns this path verbatim — bypassing both
|
package/dist/config.js
CHANGED
|
@@ -152,6 +152,15 @@ exports.ENV = {
|
|
|
152
152
|
* module loads (see `src/cli/dev-mode-bootstrap.ts`).
|
|
153
153
|
*/
|
|
154
154
|
DEV_MODE: 'AGENT_TEMPO_DEV_MODE',
|
|
155
|
+
/**
|
|
156
|
+
* #672 — set to `'1'` by a TRANSIENT-CLI spawner (e.g. the short-lived `up`
|
|
157
|
+
* conductor) on a process it intentionally DETACHES to outlive that spawner.
|
|
158
|
+
* Tells the parent-death watchdog to skip ONLY the ppid-poll signal (which
|
|
159
|
+
* would otherwise self-kill the detached process when the transient spawner
|
|
160
|
+
* exits); the universally-correct stdin-EOF signal stays. Daemon-recruit
|
|
161
|
+
* spawns do NOT set it, so recruited adapters keep the #604 anti-leak ppid-poll.
|
|
162
|
+
*/
|
|
163
|
+
NO_PPID_WATCHDOG: 'AGENT_TEMPO_NO_PPID_WATCHDOG',
|
|
155
164
|
/**
|
|
156
165
|
* Escape hatch for triple-isolated environments (ADR 0014 §5.3). When
|
|
157
166
|
* set, `resolveTempoHome()` returns this path verbatim — bypassing both
|
package/dist/http/server.js
CHANGED
|
@@ -546,9 +546,20 @@ async function handle(req, res, ctx) {
|
|
|
546
546
|
}
|
|
547
547
|
// Validate existence before opening the SSE stream — clean 404 when
|
|
548
548
|
// the ensemble was never live, instead of an empty stream.
|
|
549
|
+
//
|
|
550
|
+
// #673 — `listEnsembles` is a Temporal VISIBILITY query (eventually
|
|
551
|
+
// consistent; ~seconds behind on Temporal Cloud), so immediately after
|
|
552
|
+
// `up`/creation the just-started maestro hub isn't indexed yet and this
|
|
553
|
+
// gate would 404 — which the subscribe client classes as PERMANENT, leaving
|
|
554
|
+
// the TUI stuck on "Loading messages…". Before 404'ing, fall back to a
|
|
555
|
+
// STRONGLY-CONSISTENT describe of the maestro hub (`ensembleExists`): a
|
|
556
|
+
// RUNNING hub means the ensemble is live even if visibility hasn't caught up.
|
|
549
557
|
const list = await ctx.client.listEnsembles().catch(() => []);
|
|
550
558
|
if (!list.find((e) => e.name === ensemble)) {
|
|
551
|
-
|
|
559
|
+
const existsStrong = await ctx.client.ensembleExists(ensemble).catch(() => false);
|
|
560
|
+
if (!existsStrong) {
|
|
561
|
+
return (0, responses_1.errorResponse)(res, 404, { error: 'ensemble-not-found', ensemble });
|
|
562
|
+
}
|
|
552
563
|
}
|
|
553
564
|
const bus = ctx.aggregate.getOrCreateEnsembleBus(ensemble);
|
|
554
565
|
return (0, sse_handler_1.handleSseRequest)(req, res, {
|
package/dist/pi/cue-pump.d.ts
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cue pump — pulls cues queued on the session workflow and injects them into
|
|
3
|
-
*
|
|
2
|
+
* Cue pump — pulls cues queued on the session workflow and injects them into the
|
|
3
|
+
* LIVE Pi agent, then acks them.
|
|
4
4
|
*
|
|
5
5
|
* Pi has no reverse-RPC into a running session from Temporal, so (like the
|
|
6
6
|
* existing adapters) we poll `pendingMessages` and ack via `markDelivered`.
|
|
7
7
|
*
|
|
8
|
+
* ── Injection target: the STABLE `pi` handle, re-resolved per tick (#677) ──
|
|
9
|
+
* Pi 0.78.1's `SessionStartEvent` carries NO `session` field, so in INTERACTIVE
|
|
10
|
+
* mode `PiEventPayload.session` is null → the old `resolveSession` returned null
|
|
11
|
+
* every tick → the interactive Pi conductor NEVER received cues. The fix routes
|
|
12
|
+
* injection through the `pi` ExtensionAPI handle (`pi.sendMessage`), which is
|
|
13
|
+
* always live. Crucially the injector is RE-RESOLVED PER TICK from the surviving
|
|
14
|
+
* module-scope runtime — capturing it once silently dies after an interactive
|
|
15
|
+
* session switch (the runtime's `pi` is repointed on rebind). Headless still works
|
|
16
|
+
* (its `pi` is the real ExtensionAPI too); the legacy `session.sendCustomMessage`
|
|
17
|
+
* path is kept as a feature-detected fallback.
|
|
18
|
+
*
|
|
8
19
|
* Injection follows D10 cue-delivery semantics:
|
|
9
20
|
* - **deliverAs** — operator cue (`msg.isMaestro`, a human steering from the
|
|
10
21
|
* Maestro dashboard) → `'steer'` (interrupt the in-flight turn so the
|
|
@@ -16,44 +27,106 @@
|
|
|
16
27
|
* is a no-op when a turn is already running (the message just queues), so we
|
|
17
28
|
* don't need to race-check the idle state — set it unconditionally.
|
|
18
29
|
*
|
|
30
|
+
* ── Escalation (#677): turn-started → sendUserMessage ──
|
|
31
|
+
* `triggerTurn: true` on `sendMessage` SHOULD wake a cold-idle agent, but if it
|
|
32
|
+
* doesn't (e.g. a Pi regression, or a queued followUp that never drains), the
|
|
33
|
+
* cue sits unprocessed and silently. The pump therefore tracks the last cue it
|
|
34
|
+
* injected via the escalation-eligible `pi.sendMessage` route; on the NEXT tick,
|
|
35
|
+
* if NO turn started since (the runtime's `lastTurnStartAt` is still older than
|
|
36
|
+
* the inject), it re-injects the SAME cue via `pi.sendUserMessage` — a user-role
|
|
37
|
+
* message ALWAYS starts a turn. Escalation fires at most once per cue (it can't
|
|
38
|
+
* loop). The primary route stays `pi.sendMessage` so the `cue` customType +
|
|
39
|
+
* operator-vs-peer steer/followUp semantics are preserved; `sendUserMessage`
|
|
40
|
+
* loses both, so it is fallback-only.
|
|
41
|
+
*
|
|
19
42
|
* Adapted from Pi's `examples/extensions/file-trigger.ts`.
|
|
20
43
|
*/
|
|
21
44
|
import type { Message } from '../types';
|
|
22
|
-
import type { PiAgentSession } from './pi-types';
|
|
45
|
+
import type { ExtensionAPI, PiAgentSession, PiOutboundMessage, PiCustomMessageOptions } from './pi-types';
|
|
23
46
|
/** Source of pending cues + ack — satisfied by `PiWorkflowClient`. */
|
|
24
47
|
export interface CueSource {
|
|
25
48
|
fetchPending(): Promise<Message[]>;
|
|
26
49
|
ackDelivered(messageIds: string[]): Promise<void>;
|
|
27
50
|
}
|
|
28
51
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
52
|
+
* The live cue-injection capability, RE-RESOLVED each tick from the surviving
|
|
53
|
+
* runtime so a session switch never injects through a stale handle. Two routes:
|
|
54
|
+
* - PRIMARY (`pi.sendMessage`): preserves the `cue` customType + steer/followUp
|
|
55
|
+
* operator-vs-peer semantics. Escalation-eligible.
|
|
56
|
+
* - FALLBACK (`session.sendCustomMessage`): legacy path; NOT escalation-eligible.
|
|
57
|
+
*/
|
|
58
|
+
export interface MessageInjector {
|
|
59
|
+
/** Inject one cue (D10 — `cue` customType, steer/followUp + triggerTurn). */
|
|
60
|
+
inject(msg: PiOutboundMessage, opts: PiCustomMessageOptions): void | Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* Re-inject the SAME cue as a user-role message (always wakes a turn). Present
|
|
63
|
+
* ONLY on the escalation-eligible `pi.sendMessage` route — its presence IS the
|
|
64
|
+
* "this route can escalate" signal (the legacy session fallback omits it).
|
|
65
|
+
*/
|
|
66
|
+
escalate?(text: string): void | Promise<void>;
|
|
67
|
+
/** Epoch-ms of the last observed `turn_start` (null = none yet) — drives escalation. */
|
|
68
|
+
lastTurnStartAt(): number | null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolves the CURRENT injection capability at tick time. Re-acquired every tick
|
|
72
|
+
* rather than captured once, so a Pi instance rebuild (D11) never injects through
|
|
73
|
+
* a stale `pi`/session. Returns `null` when nothing is attached yet.
|
|
32
74
|
*/
|
|
33
|
-
export type
|
|
75
|
+
export type InjectorResolver = () => MessageInjector | null;
|
|
76
|
+
/** The runtime slice {@link buildPiInjector} reads — satisfied by `PiPlayerRuntime`. */
|
|
77
|
+
export interface InjectorRuntime {
|
|
78
|
+
pi: ExtensionAPI | null;
|
|
79
|
+
session: PiAgentSession | null;
|
|
80
|
+
lastTurnStartAt: number | null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build the per-tick {@link MessageInjector} from the live runtime, PREFERRING the
|
|
84
|
+
* stable `pi.sendMessage` handle (interactive root-cause fix, #677) and falling
|
|
85
|
+
* back to `session.sendCustomMessage` only when `pi.sendMessage` is unavailable.
|
|
86
|
+
* Pure + feature-detected (`typeof`) so it's safe whatever Pi build is loaded and
|
|
87
|
+
* unit-testable without a real Pi.
|
|
88
|
+
*/
|
|
89
|
+
export declare function buildPiInjector(rt: InjectorRuntime | null | undefined): MessageInjector | null;
|
|
34
90
|
export interface CuePumpOptions {
|
|
35
91
|
source: CueSource;
|
|
36
|
-
|
|
92
|
+
resolveInjector: InjectorResolver;
|
|
37
93
|
/** Poll interval (ms). */
|
|
38
94
|
intervalMs?: number;
|
|
95
|
+
/** Injected clock (tests). Defaults to `Date.now`. */
|
|
96
|
+
now?: () => number;
|
|
39
97
|
}
|
|
40
98
|
export declare class CuePump {
|
|
41
99
|
private readonly source;
|
|
42
|
-
private readonly
|
|
100
|
+
private readonly resolveInjector;
|
|
43
101
|
private readonly intervalMs;
|
|
102
|
+
private readonly now;
|
|
44
103
|
private timer;
|
|
45
104
|
private draining;
|
|
105
|
+
/**
|
|
106
|
+
* The last cue injected via the escalation-eligible `pi.sendMessage` route,
|
|
107
|
+
* pending a turn-start check on the next tick. Cleared once a turn starts or
|
|
108
|
+
* once escalated (escalate-once invariant).
|
|
109
|
+
*/
|
|
110
|
+
private lastInject;
|
|
46
111
|
constructor(opts: CuePumpOptions);
|
|
47
112
|
start(): void;
|
|
48
113
|
stop(): void;
|
|
49
114
|
/**
|
|
50
|
-
* One poll cycle:
|
|
51
|
-
*
|
|
52
|
-
* overlaps the
|
|
115
|
+
* One poll cycle: (1) escalate a previously-injected cue that never woke a turn,
|
|
116
|
+
* then (2) fetch pending cues, inject each into the live agent, ack the ones
|
|
117
|
+
* successfully injected. Re-entrancy guarded so a slow tick never overlaps the
|
|
118
|
+
* next interval.
|
|
53
119
|
*/
|
|
54
120
|
tick(): Promise<void>;
|
|
55
121
|
/**
|
|
56
|
-
*
|
|
122
|
+
* If a previously sendMessage-injected cue has not been followed by a turn, the
|
|
123
|
+
* `triggerTurn` wake didn't take — re-inject the SAME cue as a user-role message
|
|
124
|
+
* (always starts a turn). Escalates at most once per cue; clears the tracker
|
|
125
|
+
* once a turn is observed.
|
|
126
|
+
*/
|
|
127
|
+
private maybeEscalate;
|
|
128
|
+
/**
|
|
129
|
+
* Inject one cue into the live agent (D10 — see file header). Operator cues
|
|
57
130
|
* `steer` (same-turn priority); peer cues `followUp` (queue). `triggerTurn` is
|
|
58
131
|
* always set: a no-op mid-turn, the required cold-idle wake otherwise.
|
|
59
132
|
*/
|
package/dist/pi/cue-pump.js
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.CuePump = void 0;
|
|
4
|
+
exports.buildPiInjector = buildPiInjector;
|
|
5
|
+
/**
|
|
6
|
+
* Build the per-tick {@link MessageInjector} from the live runtime, PREFERRING the
|
|
7
|
+
* stable `pi.sendMessage` handle (interactive root-cause fix, #677) and falling
|
|
8
|
+
* back to `session.sendCustomMessage` only when `pi.sendMessage` is unavailable.
|
|
9
|
+
* Pure + feature-detected (`typeof`) so it's safe whatever Pi build is loaded and
|
|
10
|
+
* unit-testable without a real Pi.
|
|
11
|
+
*/
|
|
12
|
+
function buildPiInjector(rt) {
|
|
13
|
+
if (!rt)
|
|
14
|
+
return null;
|
|
15
|
+
const pi = rt.pi;
|
|
16
|
+
const send = typeof pi?.sendMessage === 'function' ? pi.sendMessage.bind(pi) : null;
|
|
17
|
+
if (send) {
|
|
18
|
+
const sendUser = typeof pi?.sendUserMessage === 'function' ? pi.sendUserMessage.bind(pi) : null;
|
|
19
|
+
return {
|
|
20
|
+
inject: (msg, opts) => send(msg, opts),
|
|
21
|
+
...(sendUser ? { escalate: (text) => sendUser(text) } : {}),
|
|
22
|
+
lastTurnStartAt: () => rt.lastTurnStartAt,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const session = rt.session;
|
|
26
|
+
if (session) {
|
|
27
|
+
return {
|
|
28
|
+
inject: (msg, opts) => session.sendCustomMessage(msg, opts),
|
|
29
|
+
lastTurnStartAt: () => rt.lastTurnStartAt,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
4
34
|
const DEFAULT_POLL_MS = 1_000;
|
|
5
35
|
const log = (...args) => {
|
|
6
36
|
// eslint-disable-next-line no-console
|
|
@@ -8,14 +38,22 @@ const log = (...args) => {
|
|
|
8
38
|
};
|
|
9
39
|
class CuePump {
|
|
10
40
|
source;
|
|
11
|
-
|
|
41
|
+
resolveInjector;
|
|
12
42
|
intervalMs;
|
|
43
|
+
now;
|
|
13
44
|
timer = null;
|
|
14
45
|
draining = false;
|
|
46
|
+
/**
|
|
47
|
+
* The last cue injected via the escalation-eligible `pi.sendMessage` route,
|
|
48
|
+
* pending a turn-start check on the next tick. Cleared once a turn starts or
|
|
49
|
+
* once escalated (escalate-once invariant).
|
|
50
|
+
*/
|
|
51
|
+
lastInject = null;
|
|
15
52
|
constructor(opts) {
|
|
16
53
|
this.source = opts.source;
|
|
17
|
-
this.
|
|
54
|
+
this.resolveInjector = opts.resolveInjector;
|
|
18
55
|
this.intervalMs = opts.intervalMs ?? DEFAULT_POLL_MS;
|
|
56
|
+
this.now = opts.now ?? Date.now;
|
|
19
57
|
}
|
|
20
58
|
start() {
|
|
21
59
|
if (this.timer)
|
|
@@ -33,28 +71,40 @@ class CuePump {
|
|
|
33
71
|
}
|
|
34
72
|
}
|
|
35
73
|
/**
|
|
36
|
-
* One poll cycle:
|
|
37
|
-
*
|
|
38
|
-
* overlaps the
|
|
74
|
+
* One poll cycle: (1) escalate a previously-injected cue that never woke a turn,
|
|
75
|
+
* then (2) fetch pending cues, inject each into the live agent, ack the ones
|
|
76
|
+
* successfully injected. Re-entrancy guarded so a slow tick never overlaps the
|
|
77
|
+
* next interval.
|
|
39
78
|
*/
|
|
40
79
|
async tick() {
|
|
41
80
|
if (this.draining)
|
|
42
81
|
return;
|
|
43
82
|
this.draining = true;
|
|
44
83
|
try {
|
|
84
|
+
const injector = this.resolveInjector();
|
|
85
|
+
// (1) Escalation check — runs even with no new pending. If the previous
|
|
86
|
+
// tick injected a cue via pi.sendMessage and NO turn has started since, the
|
|
87
|
+
// cue may be sitting in a cold-idle agent's queue unprocessed → re-inject as
|
|
88
|
+
// a user message (which always wakes a turn). Once per cue.
|
|
89
|
+
await this.maybeEscalate(injector);
|
|
45
90
|
const pending = await this.source.fetchPending();
|
|
46
91
|
if (pending.length === 0)
|
|
47
92
|
return;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
//
|
|
93
|
+
if (!injector) {
|
|
94
|
+
// No live injection target yet (no `pi` handle / session) — leave cues
|
|
95
|
+
// queued; next tick retries once an instance attaches/rebinds. Logged so a
|
|
96
|
+
// live bring-up can see cues are HELD (not lost) while waiting to attach.
|
|
97
|
+
log(`no live injector — holding ${pending.length} cue(s) for next tick`);
|
|
51
98
|
return;
|
|
52
99
|
}
|
|
53
100
|
const delivered = [];
|
|
101
|
+
let lastDeliveredText = null;
|
|
54
102
|
for (const msg of pending) {
|
|
103
|
+
const content = msg.from ? `[cue from ${msg.from}] ${msg.text}` : msg.text;
|
|
55
104
|
try {
|
|
56
|
-
await this.injectCue(
|
|
105
|
+
await this.injectCue(injector, msg, content);
|
|
57
106
|
delivered.push(msg.id);
|
|
107
|
+
lastDeliveredText = content;
|
|
58
108
|
}
|
|
59
109
|
catch (err) {
|
|
60
110
|
log(`failed to inject cue ${msg.id}:`, err);
|
|
@@ -63,18 +113,54 @@ class CuePump {
|
|
|
63
113
|
}
|
|
64
114
|
}
|
|
65
115
|
await this.source.ackDelivered(delivered);
|
|
116
|
+
// Track ONLY the LAST cue injected via the escalation-eligible route so the
|
|
117
|
+
// NEXT tick can re-inject it as a user message if no turn started. Tracking
|
|
118
|
+
// just the last is intentional and does NOT drop earlier cues' delivery:
|
|
119
|
+
// every cue in this batch was already injected via pi.sendMessage (queued in
|
|
120
|
+
// Pi), so they ALL drain once any turn starts — escalation only needs to WAKE
|
|
121
|
+
// a turn, and re-injecting one cue as a user message does exactly that. The
|
|
122
|
+
// session-fallback route omits `escalate` → no tracking.
|
|
123
|
+
if (injector.escalate && lastDeliveredText !== null) {
|
|
124
|
+
this.lastInject = { text: lastDeliveredText, injectedAt: this.now(), escalated: false };
|
|
125
|
+
}
|
|
66
126
|
}
|
|
67
127
|
finally {
|
|
68
128
|
this.draining = false;
|
|
69
129
|
}
|
|
70
130
|
}
|
|
71
131
|
/**
|
|
72
|
-
*
|
|
132
|
+
* If a previously sendMessage-injected cue has not been followed by a turn, the
|
|
133
|
+
* `triggerTurn` wake didn't take — re-inject the SAME cue as a user-role message
|
|
134
|
+
* (always starts a turn). Escalates at most once per cue; clears the tracker
|
|
135
|
+
* once a turn is observed.
|
|
136
|
+
*/
|
|
137
|
+
async maybeEscalate(injector) {
|
|
138
|
+
const pending = this.lastInject;
|
|
139
|
+
if (!pending || pending.escalated)
|
|
140
|
+
return;
|
|
141
|
+
if (!injector?.escalate)
|
|
142
|
+
return;
|
|
143
|
+
const turnAt = injector.lastTurnStartAt();
|
|
144
|
+
if (turnAt !== null && turnAt >= pending.injectedAt) {
|
|
145
|
+
// A turn started after the inject → the cue was picked up; stop tracking.
|
|
146
|
+
this.lastInject = null;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
await injector.escalate(pending.text);
|
|
151
|
+
pending.escalated = true;
|
|
152
|
+
log('escalated un-woken cue via sendUserMessage');
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
log('cue escalation failed:', err);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Inject one cue into the live agent (D10 — see file header). Operator cues
|
|
73
160
|
* `steer` (same-turn priority); peer cues `followUp` (queue). `triggerTurn` is
|
|
74
161
|
* always set: a no-op mid-turn, the required cold-idle wake otherwise.
|
|
75
162
|
*/
|
|
76
|
-
async injectCue(
|
|
77
|
-
const content = msg.from ? `[cue from ${msg.from}] ${msg.text}` : msg.text;
|
|
163
|
+
async injectCue(injector, msg, content) {
|
|
78
164
|
// LOAD-BEARING Pi-runtime invariant (D10) — confirmed sound through Pi 0.78.x
|
|
79
165
|
// (researcher-cited; a D6 "behaviors-to-revalidate-on-bump" item):
|
|
80
166
|
// peer cue = { deliverAs: 'followUp', triggerTurn: true } → QUEUES; drains
|
|
@@ -87,9 +173,10 @@ class CuePump {
|
|
|
87
173
|
// The guarantee this comment protects: a future Pi version MUST keep followUp
|
|
88
174
|
// non-interrupting AND triggerTurn a no-op-while-busy. If that regresses, peer
|
|
89
175
|
// cues silently become preemptions, defeating operator-vs-peer. Not unit-testable
|
|
90
|
-
// here (the
|
|
91
|
-
// version floor (≥ #2860 + #5115) + a real-Pi mid-turn integration smoke.
|
|
92
|
-
|
|
176
|
+
// here (the injector is mocked) — locked by researcher confirmation + the D6 Pi
|
|
177
|
+
// version floor (≥ #2860 + #5115) + a real-Pi mid-turn integration smoke. The
|
|
178
|
+
// #677 sendUserMessage escalation is the belt-and-suspenders for a missed wake.
|
|
179
|
+
await injector.inject({ customType: 'cue', content, display: true }, { deliverAs: msg.isMaestro ? 'steer' : 'followUp', triggerTurn: true });
|
|
93
180
|
}
|
|
94
181
|
}
|
|
95
182
|
exports.CuePump = CuePump;
|