claude-tempo 0.16.2 → 0.17.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/CLAUDE.md +5 -0
- package/dist/activities/maestro.d.ts +31 -0
- package/dist/activities/maestro.js +67 -0
- package/dist/activities/resolve.d.ts +20 -0
- package/dist/activities/resolve.js +38 -2
- package/dist/cli/commands.js +65 -2
- package/dist/config.d.ts +2 -0
- package/dist/config.js +5 -0
- package/dist/copilot-bridge.js +72 -36
- package/dist/server.js +7 -1
- package/dist/tools/ensemble.js +27 -40
- package/dist/types.d.ts +43 -0
- package/dist/worker.js +3 -0
- package/dist/workflows/index.d.ts +1 -0
- package/dist/workflows/index.js +3 -1
- package/dist/workflows/maestro-signals.d.ts +16 -0
- package/dist/workflows/maestro-signals.js +17 -0
- package/dist/workflows/maestro.d.ts +2 -0
- package/dist/workflows/maestro.js +157 -0
- package/examples/agents/tempo-conductor.md +6 -0
- package/package.json +1 -1
- package/workflow-bundle.js +200 -2
package/CLAUDE.md
CHANGED
|
@@ -32,10 +32,14 @@ src/
|
|
|
32
32
|
│ ├── index.ts # Workflow exports (re-exports for worker bundle)
|
|
33
33
|
│ ├── session.ts # claude-session workflow
|
|
34
34
|
│ ├── scheduler.ts # durable scheduler workflow (one per ensemble)
|
|
35
|
+
│ ├── maestro.ts # Maestro ensemble hub workflow (one per ensemble)
|
|
36
|
+
│ ├── maestro-signals.ts # Maestro signal/query/update type definitions
|
|
35
37
|
│ ├── scheduler-signals.ts # Scheduler signal/query type definitions
|
|
36
38
|
│ └── signals.ts # Session signal/query type definitions
|
|
37
39
|
├── activities/
|
|
38
40
|
│ ├── outbox.ts # Outbox delivery activities (cue, report, stop, recruit, encore)
|
|
41
|
+
│ ├── maestro.ts # Maestro activities (refreshEnsembleState, relayCommandToConductor, fetchConductorHistory)
|
|
42
|
+
│ ├── resolve.ts # Session resolver shared by outbox + schedule-fire activities
|
|
39
43
|
│ └── schedule-fire.ts # Schedule fire activity
|
|
40
44
|
├── ensemble/
|
|
41
45
|
│ ├── schema.ts # Lineup type definitions
|
|
@@ -125,6 +129,7 @@ npm test
|
|
|
125
129
|
- **Lineup**: A YAML file defining an ensemble configuration — which players to recruit, their types, working directories, and optional startup messages. Load via `load_lineup` to bootstrap a full ensemble in one step; save via `save_lineup` to snapshot a running ensemble's state for later reuse.
|
|
126
130
|
- **Quality Gate**: A named checklist of criteria a conductor tracks to verify a task is complete. Created via `quality_gate` (conductor only), evaluated via `evaluate_gate`, and listed via `gates`. Each criterion has a `pending` → `passed` | `failed` status; the gate's aggregate status is derived automatically (all passed → `passed`, any failed → `failed`, else `open`). Gates are stored in the conductor workflow and survive `continueAsNew`.
|
|
127
131
|
- **Worktree**: A git worktree provisioned by the conductor for a player, giving them an isolated checkout on a separate branch. Managed via the `worktree` tool (conductor only): `create` provisions the worktree and notifies the player, `remove` cleans up after the task, `list` shows all active worktrees. Worktree assignments are stored in the conductor workflow (`WorktreeEntry` records: player, path, branch, gitRoot, createdAt, createdBy).
|
|
132
|
+
- **Maestro**: A durable `claudeMaestroWorkflow` (one per ensemble, ID: `claude-maestro-{ensemble}`) that acts as an ensemble state aggregator for external integrations. It periodically polls all session metadata to maintain a player snapshot and ring-buffer event log, and accepts commands via the `maestroSendCommand` update for relay to the conductor. The Maestro dashboard ([vinceblank/maestro](https://github.com/vinceblank/maestro)) connects to this workflow to display live ensemble state. Implemented in `src/workflows/maestro.ts` with activities in `src/activities/maestro.ts`.
|
|
128
133
|
- **Wire protocol**: All Temporal signal, query, update, and workflow names are documented in [`docs/WIRE-PROTOCOL.md`](docs/WIRE-PROTOCOL.md). These names are stable as of v0.10 — renaming or removing any is a breaking change requiring a major version bump.
|
|
129
134
|
|
|
130
135
|
## Dashboard
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Client } from '@temporalio/client';
|
|
2
|
+
import { HistoryEntry, MaestroPlayerInfo } from '../types';
|
|
3
|
+
export interface RelayCommandInput {
|
|
4
|
+
ensemble: string;
|
|
5
|
+
text: string;
|
|
6
|
+
source: string;
|
|
7
|
+
replyTo?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface RelayCommandResult {
|
|
10
|
+
success: boolean;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface FetchConductorHistoryInput {
|
|
14
|
+
ensemble: string;
|
|
15
|
+
}
|
|
16
|
+
export interface FetchConductorHistoryResult {
|
|
17
|
+
success: boolean;
|
|
18
|
+
history: HistoryEntry[];
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
/** Activity interface — used by proxyActivities in the Maestro workflow. */
|
|
22
|
+
export interface MaestroActivities {
|
|
23
|
+
refreshEnsembleState(ensemble: string): Promise<MaestroPlayerInfo[]>;
|
|
24
|
+
fetchConductorHistory(input: FetchConductorHistoryInput): Promise<FetchConductorHistoryResult>;
|
|
25
|
+
relayCommandToConductor(input: RelayCommandInput): Promise<RelayCommandResult>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Create the Maestro activity implementations bound to a Temporal client.
|
|
29
|
+
* Registered with the shared worker.
|
|
30
|
+
*/
|
|
31
|
+
export declare function createMaestroActivities(client: Client): MaestroActivities;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createMaestroActivities = createMaestroActivities;
|
|
4
|
+
const activity_1 = require("@temporalio/activity");
|
|
5
|
+
const config_1 = require("../config");
|
|
6
|
+
const resolve_1 = require("./resolve");
|
|
7
|
+
const log = (...args) => console.error('[claude-tempo:maestro]', ...args);
|
|
8
|
+
/**
|
|
9
|
+
* Create the Maestro activity implementations bound to a Temporal client.
|
|
10
|
+
* Registered with the shared worker.
|
|
11
|
+
*/
|
|
12
|
+
function createMaestroActivities(client) {
|
|
13
|
+
return {
|
|
14
|
+
async refreshEnsembleState(ensemble) {
|
|
15
|
+
try {
|
|
16
|
+
const sessions = await (0, resolve_1.scanEnsembleSessions)(client, ensemble);
|
|
17
|
+
return sessions.map((s) => ({
|
|
18
|
+
playerId: s.playerId,
|
|
19
|
+
part: s.part,
|
|
20
|
+
hostname: s.hostname,
|
|
21
|
+
workDir: s.workDir,
|
|
22
|
+
gitRoot: s.gitRoot,
|
|
23
|
+
gitBranch: s.gitBranch,
|
|
24
|
+
isConductor: s.isConductor,
|
|
25
|
+
agentType: s.agentType,
|
|
26
|
+
playerType: s.playerType,
|
|
27
|
+
status: s.status,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
log('refreshEnsembleState failed:', err);
|
|
32
|
+
throw activity_1.ApplicationFailure.nonRetryable(`Failed to scan ensemble: ${err instanceof Error ? err.message : String(err)}`);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
async fetchConductorHistory(input) {
|
|
36
|
+
try {
|
|
37
|
+
const wfId = (0, config_1.conductorWorkflowId)(input.ensemble);
|
|
38
|
+
const handle = client.workflow.getHandle(wfId);
|
|
39
|
+
const history = await handle.query('history');
|
|
40
|
+
return { success: true, history };
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
// ContinueAsNew transient errors and missing conductor are soft failures
|
|
44
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
45
|
+
log('fetchConductorHistory failed (soft):', msg);
|
|
46
|
+
return { success: false, history: [], error: msg };
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
async relayCommandToConductor(input) {
|
|
50
|
+
try {
|
|
51
|
+
const wfId = (0, config_1.conductorWorkflowId)(input.ensemble);
|
|
52
|
+
const handle = client.workflow.getHandle(wfId);
|
|
53
|
+
await handle.signal('command', {
|
|
54
|
+
text: input.text,
|
|
55
|
+
source: input.source,
|
|
56
|
+
...(input.replyTo ? { replyTo: input.replyTo } : {}),
|
|
57
|
+
});
|
|
58
|
+
return { success: true };
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
62
|
+
log('relayCommandToConductor failed:', msg);
|
|
63
|
+
return { success: false, error: msg };
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -8,3 +8,23 @@ import { Client, WorkflowHandle } from '@temporalio/client';
|
|
|
8
8
|
* Shared by activity files (outbox, schedule-fire) and the tools layer.
|
|
9
9
|
*/
|
|
10
10
|
export declare function resolveSession(client: Client, ensemble: string, playerName: string): Promise<WorkflowHandle | null>;
|
|
11
|
+
/** Info returned for each session by scanEnsembleSessions. */
|
|
12
|
+
export interface EnsembleSessionInfo {
|
|
13
|
+
workflowId: string;
|
|
14
|
+
playerId: string;
|
|
15
|
+
part: string;
|
|
16
|
+
hostname: string;
|
|
17
|
+
workDir: string;
|
|
18
|
+
gitRoot?: string;
|
|
19
|
+
gitBranch?: string;
|
|
20
|
+
isConductor: boolean;
|
|
21
|
+
agentType: string;
|
|
22
|
+
playerType?: string;
|
|
23
|
+
status?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Scan all running session workflows in an ensemble.
|
|
27
|
+
* Returns metadata + part for each session. Shared by the ensemble MCP tool
|
|
28
|
+
* and the Maestro refresh activity.
|
|
29
|
+
*/
|
|
30
|
+
export declare function scanEnsembleSessions(client: Client, ensemble: string): Promise<EnsembleSessionInfo[]>;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.resolveSession = resolveSession;
|
|
4
|
+
exports.scanEnsembleSessions = scanEnsembleSessions;
|
|
5
|
+
/** Shared query for listing running session workflows. */
|
|
6
|
+
const SESSION_LIST_QUERY = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
|
|
4
7
|
/**
|
|
5
8
|
* Resolve a session by player name.
|
|
6
9
|
* Lists all running session workflows and queries each for metadata.
|
|
@@ -10,8 +13,7 @@ exports.resolveSession = resolveSession;
|
|
|
10
13
|
* Shared by activity files (outbox, schedule-fire) and the tools layer.
|
|
11
14
|
*/
|
|
12
15
|
async function resolveSession(client, ensemble, playerName) {
|
|
13
|
-
|
|
14
|
-
for await (const wf of client.workflow.list({ query })) {
|
|
16
|
+
for await (const wf of client.workflow.list({ query: SESSION_LIST_QUERY })) {
|
|
15
17
|
try {
|
|
16
18
|
const handle = client.workflow.getHandle(wf.workflowId);
|
|
17
19
|
const metadata = await handle.query('getMetadata');
|
|
@@ -25,3 +27,37 @@ async function resolveSession(client, ensemble, playerName) {
|
|
|
25
27
|
}
|
|
26
28
|
return null;
|
|
27
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Scan all running session workflows in an ensemble.
|
|
32
|
+
* Returns metadata + part for each session. Shared by the ensemble MCP tool
|
|
33
|
+
* and the Maestro refresh activity.
|
|
34
|
+
*/
|
|
35
|
+
async function scanEnsembleSessions(client, ensemble) {
|
|
36
|
+
const sessions = [];
|
|
37
|
+
for await (const workflow of client.workflow.list({ query: SESSION_LIST_QUERY })) {
|
|
38
|
+
try {
|
|
39
|
+
const handle = client.workflow.getHandle(workflow.workflowId);
|
|
40
|
+
const metadata = await handle.query('getMetadata');
|
|
41
|
+
if (metadata.ensemble !== ensemble)
|
|
42
|
+
continue;
|
|
43
|
+
const part = await handle.query('getPart');
|
|
44
|
+
sessions.push({
|
|
45
|
+
workflowId: workflow.workflowId,
|
|
46
|
+
playerId: metadata.playerId,
|
|
47
|
+
part,
|
|
48
|
+
hostname: metadata.hostname,
|
|
49
|
+
workDir: metadata.workDir,
|
|
50
|
+
gitRoot: metadata.gitRoot,
|
|
51
|
+
gitBranch: metadata.gitBranch,
|
|
52
|
+
isConductor: metadata.isConductor,
|
|
53
|
+
agentType: metadata.agentType || 'claude',
|
|
54
|
+
playerType: metadata.playerType,
|
|
55
|
+
status: metadata.status,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Workflow may have just completed — skip it
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return sessions;
|
|
63
|
+
}
|
package/dist/cli/commands.js
CHANGED
|
@@ -74,6 +74,27 @@ function formatDurationMs(ms) {
|
|
|
74
74
|
return `${ms / 60_000}m`;
|
|
75
75
|
return `${ms / 1000}s`;
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Ensure the Maestro workflow is running for the given ensemble.
|
|
79
|
+
* Idempotent — uses USE_EXISTING conflict policy.
|
|
80
|
+
*/
|
|
81
|
+
async function ensureMaestroWorkflow(client, config, ensemble) {
|
|
82
|
+
const wfId = (0, config_1.maestroWorkflowId)(ensemble);
|
|
83
|
+
try {
|
|
84
|
+
await client.workflow.start('claudeMaestroWorkflow', {
|
|
85
|
+
workflowId: wfId,
|
|
86
|
+
taskQueue: config.taskQueue,
|
|
87
|
+
args: [{ ensemble }],
|
|
88
|
+
workflowIdConflictPolicy: client_1.WorkflowIdConflictPolicy.USE_EXISTING,
|
|
89
|
+
searchAttributes: {
|
|
90
|
+
ClaudeTempoEnsemble: [ensemble],
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Maestro is non-critical — log but don't fail
|
|
96
|
+
}
|
|
97
|
+
}
|
|
77
98
|
async function start(opts) {
|
|
78
99
|
const config = (0, config_1.getConfig)(opts);
|
|
79
100
|
const workDir = opts.dir || process.cwd();
|
|
@@ -190,6 +211,18 @@ async function start(opts) {
|
|
|
190
211
|
}
|
|
191
212
|
out.log(` Ensemble: ${opts.ensemble}`);
|
|
192
213
|
out.log(` Directory: ${workDir}`);
|
|
214
|
+
// Start Maestro workflow when launching a conductor
|
|
215
|
+
if (opts.conductor) {
|
|
216
|
+
try {
|
|
217
|
+
const connection = await (0, connection_1.createTemporalConnection)(config);
|
|
218
|
+
const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
|
|
219
|
+
await ensureMaestroWorkflow(client, config, opts.ensemble);
|
|
220
|
+
await connection.close();
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Maestro is non-critical
|
|
224
|
+
}
|
|
225
|
+
}
|
|
193
226
|
out.log(`\nCheck status: ${out.dim('claude-tempo status ' + opts.ensemble)}`);
|
|
194
227
|
}
|
|
195
228
|
async function status(opts) {
|
|
@@ -289,9 +322,12 @@ async function status(opts) {
|
|
|
289
322
|
const agent = s.agentType === 'copilot' ? out.dim(' [copilot]') : '';
|
|
290
323
|
const statusLabel = s.status === 'stale' ? out.yellow(' (stale)')
|
|
291
324
|
: s.status === 'pending' ? out.dim(' (pending)')
|
|
292
|
-
: ''
|
|
325
|
+
: s.status === 'blocked' ? out.yellow(' (blocked)')
|
|
326
|
+
: '';
|
|
327
|
+
// Show PID info for copilot bridge sessions
|
|
328
|
+
const pidInfo = s.agentType === 'copilot' ? getBridgePidInfo(s.name) : '';
|
|
293
329
|
const name = out.bold(s.name);
|
|
294
|
-
out.log(` ${name}${role}${statusLabel}${agent}`);
|
|
330
|
+
out.log(` ${name}${role}${statusLabel}${agent}${pidInfo}`);
|
|
295
331
|
if (s.part)
|
|
296
332
|
out.log(` ${out.dim(s.part)}`);
|
|
297
333
|
const details = [s.workDir, s.branch, s.host].filter(Boolean).join(' ');
|
|
@@ -716,6 +752,8 @@ async function up(opts) {
|
|
|
716
752
|
}
|
|
717
753
|
else {
|
|
718
754
|
out.check('Conductor registered', true);
|
|
755
|
+
// Ensure Maestro workflow is running
|
|
756
|
+
await ensureMaestroWorkflow(client, config, opts.ensemble);
|
|
719
757
|
// Send conductor instructions if provided
|
|
720
758
|
if (lineup.conductor?.instructions) {
|
|
721
759
|
try {
|
|
@@ -1129,6 +1167,31 @@ async function stopByName(client, name, config, ensemble) {
|
|
|
1129
1167
|
process.exit(1);
|
|
1130
1168
|
}
|
|
1131
1169
|
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Read PID info for a copilot bridge session from its PID file.
|
|
1172
|
+
* Returns a formatted string like " (pid 12345)" or "" if no PID file found.
|
|
1173
|
+
*/
|
|
1174
|
+
function getBridgePidInfo(name) {
|
|
1175
|
+
const pidPath = (0, path_1.join)(process.cwd(), 'logs', `${name}.pid`);
|
|
1176
|
+
if (!(0, fs_1.existsSync)(pidPath))
|
|
1177
|
+
return '';
|
|
1178
|
+
try {
|
|
1179
|
+
const pid = parseInt((0, fs_1.readFileSync)(pidPath, 'utf8').trim(), 10);
|
|
1180
|
+
if (isNaN(pid))
|
|
1181
|
+
return '';
|
|
1182
|
+
// Check if process is still alive
|
|
1183
|
+
try {
|
|
1184
|
+
process.kill(pid, 0); // signal 0 = existence check, doesn't kill
|
|
1185
|
+
return out.dim(` (pid ${pid})`);
|
|
1186
|
+
}
|
|
1187
|
+
catch {
|
|
1188
|
+
return out.dim(` (pid ${pid}, dead)`);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
catch {
|
|
1192
|
+
return '';
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1132
1195
|
/**
|
|
1133
1196
|
* Kill a bridge process by reading its PID file from logs/.
|
|
1134
1197
|
* Cleans up the PID file after.
|
package/dist/config.d.ts
CHANGED
|
@@ -94,3 +94,5 @@ export declare function sessionWorkflowId(ensemble: string, playerId: string): s
|
|
|
94
94
|
export declare function conductorWorkflowId(ensemble: string): string;
|
|
95
95
|
/** Build a workflow ID for the scheduler: claude-scheduler-{ensemble} */
|
|
96
96
|
export declare function schedulerWorkflowId(ensemble: string): string;
|
|
97
|
+
/** Build a workflow ID for the Maestro: claude-maestro-{ensemble} */
|
|
98
|
+
export declare function maestroWorkflowId(ensemble: string): string;
|
package/dist/config.js
CHANGED
|
@@ -11,6 +11,7 @@ exports.hostTaskQueue = hostTaskQueue;
|
|
|
11
11
|
exports.sessionWorkflowId = sessionWorkflowId;
|
|
12
12
|
exports.conductorWorkflowId = conductorWorkflowId;
|
|
13
13
|
exports.schedulerWorkflowId = schedulerWorkflowId;
|
|
14
|
+
exports.maestroWorkflowId = maestroWorkflowId;
|
|
14
15
|
const fs_1 = require("fs");
|
|
15
16
|
const path_1 = require("path");
|
|
16
17
|
const os_1 = require("os");
|
|
@@ -263,3 +264,7 @@ function conductorWorkflowId(ensemble) {
|
|
|
263
264
|
function schedulerWorkflowId(ensemble) {
|
|
264
265
|
return `claude-scheduler-${ensemble}`;
|
|
265
266
|
}
|
|
267
|
+
/** Build a workflow ID for the Maestro: claude-maestro-{ensemble} */
|
|
268
|
+
function maestroWorkflowId(ensemble) {
|
|
269
|
+
return `claude-maestro-${ensemble}`;
|
|
270
|
+
}
|
package/dist/copilot-bridge.js
CHANGED
|
@@ -84,6 +84,8 @@ const POLL_INTERVAL_MS = 2000;
|
|
|
84
84
|
const CREATE_SESSION_TIMEOUT_MS = 45_000;
|
|
85
85
|
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
86
86
|
const MAX_SESSION_RECREATIONS = 2;
|
|
87
|
+
/** Check workflow status every N polls (~30s at 2s interval). */
|
|
88
|
+
const WORKFLOW_STATUS_CHECK_INTERVAL = 15;
|
|
87
89
|
/** Wrap createSession with a timeout so auth/network hangs don't block forever. */
|
|
88
90
|
async function createSessionWithTimeout(copilotClient, sessionConfig, timeoutMs = CREATE_SESSION_TIMEOUT_MS) {
|
|
89
91
|
let timer;
|
|
@@ -252,6 +254,9 @@ async function main() {
|
|
|
252
254
|
catch (err) {
|
|
253
255
|
log(`Initial prompt error after ${Date.now()}ms:`, err?.message, err?.stack?.substring(0, 300));
|
|
254
256
|
}
|
|
257
|
+
// PID file paths — computed early so early-exit paths can clean up
|
|
258
|
+
const pidDir = path.join(workDir, 'logs');
|
|
259
|
+
const pidFile = path.join(pidDir, `${playerName || playerIdForWorkflow}.pid`);
|
|
255
260
|
// Wait for the MCP server's workflow to register in Temporal.
|
|
256
261
|
// We know the exact workflow ID because we pass CLAUDE_TEMPO_PLAYER_NAME to the
|
|
257
262
|
// MCP server — no need for a time-window heuristic that could misidentify workflows.
|
|
@@ -277,6 +282,11 @@ async function main() {
|
|
|
277
282
|
log(`ERROR: Workflow ${expectedWorkflowId} did not register within 30 seconds`);
|
|
278
283
|
await session.disconnect();
|
|
279
284
|
await copilotClient.stop();
|
|
285
|
+
// Clean up PID file to avoid stale entries in `claude-tempo status`
|
|
286
|
+
try {
|
|
287
|
+
fs.unlinkSync(pidFile);
|
|
288
|
+
}
|
|
289
|
+
catch { /* may not exist yet */ }
|
|
280
290
|
process.exit(1);
|
|
281
291
|
}
|
|
282
292
|
log(`Workflow ready: ${expectedWorkflowId}`);
|
|
@@ -288,6 +298,15 @@ async function main() {
|
|
|
288
298
|
log(`set_name completed in ${Date.now() - t0}ms`);
|
|
289
299
|
}
|
|
290
300
|
const MAESTRO_ACK = '\n\n[IMPORTANT: This message is from a human (Maestro). Immediately cue the sender back with a brief acknowledgment and your planned next step before doing the work.]';
|
|
301
|
+
// Write PID file so callers can find/kill orphaned bridge processes
|
|
302
|
+
try {
|
|
303
|
+
fs.mkdirSync(pidDir, { recursive: true });
|
|
304
|
+
fs.writeFileSync(pidFile, String(process.pid));
|
|
305
|
+
log(`PID file written: ${pidFile}`);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
log(`Warning: could not write PID file: ${err?.message}`);
|
|
309
|
+
}
|
|
291
310
|
// Start message poller — inject messages into the Copilot session.
|
|
292
311
|
// Tracks consecutive failures and attempts session recreation before giving up.
|
|
293
312
|
let polling = true;
|
|
@@ -295,6 +314,36 @@ async function main() {
|
|
|
295
314
|
let pollCount = 0;
|
|
296
315
|
let consecutiveFailures = 0;
|
|
297
316
|
let sessionRecreations = 0;
|
|
317
|
+
// interval declared here, assigned after poll is defined
|
|
318
|
+
let interval;
|
|
319
|
+
// Shared cleanup — disconnects session, removes PID file, stops client.
|
|
320
|
+
// `signalTermination` controls whether we also signal the workflow to terminate
|
|
321
|
+
// (skip if the workflow is already gone).
|
|
322
|
+
let shuttingDown = false;
|
|
323
|
+
const cleanup = async (signalTermination) => {
|
|
324
|
+
if (shuttingDown)
|
|
325
|
+
return;
|
|
326
|
+
shuttingDown = true;
|
|
327
|
+
polling = false;
|
|
328
|
+
clearInterval(interval);
|
|
329
|
+
if (signalTermination) {
|
|
330
|
+
try {
|
|
331
|
+
await handle.signal('updateMetadata', { status: 'terminated', terminatedBy: 'system' });
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
// workflow may already be gone
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
await session.disconnect();
|
|
339
|
+
}
|
|
340
|
+
catch { /* already disconnected */ }
|
|
341
|
+
try {
|
|
342
|
+
fs.unlinkSync(pidFile);
|
|
343
|
+
}
|
|
344
|
+
catch { /* already gone */ }
|
|
345
|
+
await copilotClient.stop();
|
|
346
|
+
};
|
|
298
347
|
/** Attempt to recreate the Copilot session after repeated failures. */
|
|
299
348
|
async function recreateSession() {
|
|
300
349
|
sessionRecreations++;
|
|
@@ -326,6 +375,24 @@ async function main() {
|
|
|
326
375
|
const silenceSec = ((Date.now() - lastEventTime) / 1000).toFixed(0);
|
|
327
376
|
log(`[health] poll #${pollCount}, sessionAlive=${sessionAlive}, lastEvent=${lastEventType} ${silenceSec}s ago`);
|
|
328
377
|
}
|
|
378
|
+
// Periodic workflow status check — detect external termination/completion
|
|
379
|
+
if (pollCount % WORKFLOW_STATUS_CHECK_INTERVAL === 0) {
|
|
380
|
+
try {
|
|
381
|
+
const desc = await handle.describe();
|
|
382
|
+
const wfStatus = desc.status.name;
|
|
383
|
+
if (wfStatus !== 'RUNNING') {
|
|
384
|
+
log(`Workflow status is ${wfStatus} — exiting cleanly`);
|
|
385
|
+
await cleanup(false); // workflow already gone, don't signal
|
|
386
|
+
process.exit(0);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
// If we can't describe (e.g., workflow not found), it was likely terminated
|
|
391
|
+
log(`Workflow describe failed: ${err?.message} — treating as terminated`);
|
|
392
|
+
await cleanup(false);
|
|
393
|
+
process.exit(0);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
329
396
|
try {
|
|
330
397
|
const messages = await handle.query('pendingMessages');
|
|
331
398
|
if (messages.length === 0)
|
|
@@ -365,49 +432,18 @@ async function main() {
|
|
|
365
432
|
const recovered = await recreateSession();
|
|
366
433
|
if (!recovered) {
|
|
367
434
|
log('ERROR: Session recovery failed. Shutting down bridge.');
|
|
368
|
-
|
|
369
|
-
clearInterval(interval);
|
|
435
|
+
await cleanup(true);
|
|
370
436
|
process.exit(2);
|
|
371
437
|
}
|
|
372
438
|
}
|
|
373
439
|
}
|
|
374
440
|
};
|
|
375
|
-
|
|
441
|
+
interval = setInterval(poll, POLL_INTERVAL_MS);
|
|
376
442
|
log('Message poller started. Bridge is running.');
|
|
377
|
-
//
|
|
378
|
-
const pidDir = path.join(workDir, 'logs');
|
|
379
|
-
const pidFile = path.join(pidDir, `${playerName || playerIdForWorkflow}.pid`);
|
|
380
|
-
try {
|
|
381
|
-
fs.mkdirSync(pidDir, { recursive: true });
|
|
382
|
-
fs.writeFileSync(pidFile, String(process.pid));
|
|
383
|
-
log(`PID file written: ${pidFile}`);
|
|
384
|
-
}
|
|
385
|
-
catch (err) {
|
|
386
|
-
log(`Warning: could not write PID file: ${err?.message}`);
|
|
387
|
-
}
|
|
388
|
-
// Graceful shutdown
|
|
443
|
+
// Graceful shutdown on SIGINT/SIGTERM — signal the workflow before exiting
|
|
389
444
|
const shutdown = async () => {
|
|
390
|
-
log('Shutting down...');
|
|
391
|
-
|
|
392
|
-
clearInterval(interval);
|
|
393
|
-
try {
|
|
394
|
-
await handle.signal('updateMetadata', { status: 'terminated', terminatedBy: 'system' });
|
|
395
|
-
}
|
|
396
|
-
catch {
|
|
397
|
-
// workflow may already be gone
|
|
398
|
-
}
|
|
399
|
-
try {
|
|
400
|
-
await session.disconnect();
|
|
401
|
-
}
|
|
402
|
-
catch {
|
|
403
|
-
// session may already be disconnected
|
|
404
|
-
}
|
|
405
|
-
// Clean up PID file
|
|
406
|
-
try {
|
|
407
|
-
fs.unlinkSync(pidFile);
|
|
408
|
-
}
|
|
409
|
-
catch { /* may already be gone */ }
|
|
410
|
-
await copilotClient.stop();
|
|
445
|
+
log('Shutting down (signal received)...');
|
|
446
|
+
await cleanup(true);
|
|
411
447
|
process.exit(0);
|
|
412
448
|
};
|
|
413
449
|
process.on('SIGINT', shutdown);
|
package/dist/server.js
CHANGED
|
@@ -255,7 +255,13 @@ async function main() {
|
|
|
255
255
|
`Use \`ensemble\` to see who else is active. ` +
|
|
256
256
|
`Use \`cue\` to reply directly to the player who messaged you, or to ask others for help. ` +
|
|
257
257
|
`Use \`recruit\` if you need a session in a directory where none exists. ` +
|
|
258
|
-
`Use \`report\` to notify the conductor of task completion, blockers, or questions — always report when you finish a recruited task
|
|
258
|
+
`Use \`report\` to notify the conductor of task completion, blockers, or questions — always report when you finish a recruited task.` +
|
|
259
|
+
(isConductor
|
|
260
|
+
? `\n\nOperational rules:\n` +
|
|
261
|
+
`- Before assigning parallel work on different branches, provision git worktrees via the \`worktree\` tool so each player has an isolated checkout.\n` +
|
|
262
|
+
`- No player should switch branches without your approval — if a player needs a different branch, provision a worktree for them.\n` +
|
|
263
|
+
`- Before shipping, verify the branch diff scope matches the assigned task (no unrelated changes).`
|
|
264
|
+
: `\n\nDo not switch git branches without the conductor's approval. If no conductor exists, broadcast your intent to the ensemble first. Prefer using the \`worktree\` tool for branch isolation.`);
|
|
259
265
|
const mcpServer = new mcp_js_1.McpServer({
|
|
260
266
|
name: 'claude-tempo',
|
|
261
267
|
version: PKG_VERSION,
|
package/dist/tools/ensemble.js
CHANGED
|
@@ -36,53 +36,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.registerEnsembleTool = registerEnsembleTool;
|
|
37
37
|
const zod_1 = require("zod");
|
|
38
38
|
const os = __importStar(require("os"));
|
|
39
|
+
const resolve_1 = require("../activities/resolve");
|
|
39
40
|
const helpers_1 = require("./helpers");
|
|
40
41
|
function registerEnsembleTool(server, client, config, getPlayerId, ownWorkflowId) {
|
|
41
42
|
(0, helpers_1.defineTool)(server, 'ensemble', `Discover active Claude Code sessions in the "${config.ensemble}" ensemble. Returns player IDs, descriptions, and metadata.`, {
|
|
42
43
|
scope: zod_1.z.string().optional().describe('Filter scope: "machine" (same hostname), "repo" (same git root), "all" (default). All scopes are within the current ensemble.'),
|
|
43
44
|
}, async (args) => {
|
|
44
45
|
const scope = (args.scope ?? 'all');
|
|
45
|
-
|
|
46
|
-
// in-memory metadata queries. This avoids depending on custom search
|
|
47
|
-
// attributes which are eventually consistent and may be missing/stale.
|
|
48
|
-
const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
|
|
49
|
-
const players = [];
|
|
46
|
+
let sessions;
|
|
50
47
|
try {
|
|
51
|
-
|
|
52
|
-
try {
|
|
53
|
-
const handle = client.workflow.getHandle(workflow.workflowId);
|
|
54
|
-
const metadata = await handle.query('getMetadata');
|
|
55
|
-
// Filter by ensemble
|
|
56
|
-
if (metadata.ensemble !== config.ensemble)
|
|
57
|
-
continue;
|
|
58
|
-
// Filter by scope
|
|
59
|
-
if (scope === 'machine' && metadata.hostname !== os.hostname())
|
|
60
|
-
continue;
|
|
61
|
-
if (scope === 'repo') {
|
|
62
|
-
const ownHandle = client.workflow.getHandle(ownWorkflowId);
|
|
63
|
-
const ownMeta = await ownHandle.query('getMetadata');
|
|
64
|
-
if (metadata.gitRoot !== ownMeta.gitRoot)
|
|
65
|
-
continue;
|
|
66
|
-
}
|
|
67
|
-
const part = await handle.query('getPart');
|
|
68
|
-
players.push({
|
|
69
|
-
playerId: metadata.playerId,
|
|
70
|
-
part,
|
|
71
|
-
hostname: metadata.hostname,
|
|
72
|
-
workDir: metadata.workDir,
|
|
73
|
-
gitRoot: metadata.gitRoot,
|
|
74
|
-
gitBranch: metadata.gitBranch,
|
|
75
|
-
isConductor: metadata.isConductor,
|
|
76
|
-
agentType: metadata.agentType || 'claude',
|
|
77
|
-
playerType: metadata.playerType,
|
|
78
|
-
status: metadata.status,
|
|
79
|
-
isYou: metadata.playerId === getPlayerId(),
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
// Workflow may have just completed — skip it
|
|
84
|
-
}
|
|
85
|
-
}
|
|
48
|
+
sessions = await (0, resolve_1.scanEnsembleSessions)(client, config.ensemble);
|
|
86
49
|
}
|
|
87
50
|
catch (err) {
|
|
88
51
|
return {
|
|
@@ -90,6 +53,30 @@ function registerEnsembleTool(server, client, config, getPlayerId, ownWorkflowId
|
|
|
90
53
|
isError: true,
|
|
91
54
|
};
|
|
92
55
|
}
|
|
56
|
+
// Apply scope filters
|
|
57
|
+
let ownGitRoot;
|
|
58
|
+
if (scope === 'repo') {
|
|
59
|
+
try {
|
|
60
|
+
const ownHandle = client.workflow.getHandle(ownWorkflowId);
|
|
61
|
+
const ownMeta = await ownHandle.query('getMetadata');
|
|
62
|
+
ownGitRoot = ownMeta.gitRoot;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Can't determine own git root — skip repo filtering
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const players = sessions
|
|
69
|
+
.filter((s) => {
|
|
70
|
+
if (scope === 'machine' && s.hostname !== os.hostname())
|
|
71
|
+
return false;
|
|
72
|
+
if (scope === 'repo' && ownGitRoot && s.gitRoot !== ownGitRoot)
|
|
73
|
+
return false;
|
|
74
|
+
return true;
|
|
75
|
+
})
|
|
76
|
+
.map((s) => ({
|
|
77
|
+
...s,
|
|
78
|
+
isYou: s.playerId === getPlayerId(),
|
|
79
|
+
}));
|
|
93
80
|
if (players.length === 0) {
|
|
94
81
|
return {
|
|
95
82
|
content: [{ type: 'text', text: 'No active sessions found.' }],
|
package/dist/types.d.ts
CHANGED
|
@@ -198,4 +198,47 @@ export interface ScheduleEntry {
|
|
|
198
198
|
/** IANA timezone for cron evaluation (e.g., "America/New_York"). Defaults to UTC. */
|
|
199
199
|
timezone?: string;
|
|
200
200
|
}
|
|
201
|
+
/** Snapshot of a player as seen by the Maestro workflow. */
|
|
202
|
+
export interface MaestroPlayerInfo {
|
|
203
|
+
playerId: string;
|
|
204
|
+
part: string;
|
|
205
|
+
hostname: string;
|
|
206
|
+
workDir: string;
|
|
207
|
+
gitRoot?: string;
|
|
208
|
+
gitBranch?: string;
|
|
209
|
+
isConductor: boolean;
|
|
210
|
+
agentType: string;
|
|
211
|
+
playerType?: string;
|
|
212
|
+
status?: string;
|
|
213
|
+
}
|
|
214
|
+
/** An event generated by diffing consecutive Maestro snapshots. */
|
|
215
|
+
export interface MaestroEvent {
|
|
216
|
+
type: 'player_joined' | 'player_left' | 'status_changed' | 'part_changed';
|
|
217
|
+
playerId: string;
|
|
218
|
+
timestamp: string;
|
|
219
|
+
oldValue?: string;
|
|
220
|
+
newValue?: string;
|
|
221
|
+
}
|
|
222
|
+
/** A command queued via the maestroSendCommand update, awaiting relay. */
|
|
223
|
+
export interface MaestroPendingCommand {
|
|
224
|
+
id: string;
|
|
225
|
+
text: string;
|
|
226
|
+
source: string;
|
|
227
|
+
replyTo?: string;
|
|
228
|
+
createdAt: string;
|
|
229
|
+
status: 'pending' | 'delivered' | 'failed';
|
|
230
|
+
error?: string;
|
|
231
|
+
}
|
|
232
|
+
/** Input for the Maestro workflow. */
|
|
233
|
+
export interface MaestroInput {
|
|
234
|
+
ensemble: string;
|
|
235
|
+
/** Restored from continue-as-new. */
|
|
236
|
+
players?: MaestroPlayerInfo[];
|
|
237
|
+
/** Restored from continue-as-new (ring buffer, max 200). */
|
|
238
|
+
events?: MaestroEvent[];
|
|
239
|
+
/** Restored from continue-as-new. */
|
|
240
|
+
pendingCommands?: MaestroPendingCommand[];
|
|
241
|
+
/** Refresh interval in milliseconds (default 10000). Lowered in tests. */
|
|
242
|
+
pollIntervalMs?: number;
|
|
243
|
+
}
|
|
201
244
|
export {};
|
package/dist/worker.js
CHANGED
|
@@ -45,6 +45,7 @@ const connection_1 = require("./connection");
|
|
|
45
45
|
const connection_2 = require("./connection");
|
|
46
46
|
const schedule_fire_1 = require("./activities/schedule-fire");
|
|
47
47
|
const outbox_1 = require("./activities/outbox");
|
|
48
|
+
const maestro_1 = require("./activities/maestro");
|
|
48
49
|
const log = (...args) => console.error('[claude-tempo:worker]', ...args);
|
|
49
50
|
const BUNDLE_PATH = path.resolve(__dirname, '..', 'workflow-bundle.js');
|
|
50
51
|
async function getWorkflowBundle() {
|
|
@@ -73,6 +74,7 @@ async function createWorkers(config) {
|
|
|
73
74
|
const client = new client_1.Client({ connection: clientConnection, namespace: config.temporalNamespace });
|
|
74
75
|
const scheduleActivities = (0, schedule_fire_1.createScheduleActivities)(client);
|
|
75
76
|
const outboxActivities = (0, outbox_1.createOutboxActivities)(client, config);
|
|
77
|
+
const maestroActivities = (0, maestro_1.createMaestroActivities)(client);
|
|
76
78
|
const workflowBundle = await getWorkflowBundle();
|
|
77
79
|
const SHUTDOWN_GRACE_TIME = '10s';
|
|
78
80
|
const SHUTDOWN_FORCE_TIME = '15s';
|
|
@@ -85,6 +87,7 @@ async function createWorkers(config) {
|
|
|
85
87
|
shutdownForceTime: SHUTDOWN_FORCE_TIME,
|
|
86
88
|
activities: {
|
|
87
89
|
...scheduleActivities,
|
|
90
|
+
...maestroActivities,
|
|
88
91
|
// Shared-queue delivery activities (everything except spawnProcess)
|
|
89
92
|
deliverCue: outboxActivities.deliverCue,
|
|
90
93
|
deliverReport: outboxActivities.deliverReport,
|