claude-tempo 0.26.0-beta.7 → 0.26.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 -1
- package/README.md +1 -0
- package/assets/icon-512.png +0 -0
- package/dist/cli/commands.d.ts +24 -0
- package/dist/cli/commands.js +79 -0
- package/dist/cli/help-text.js +2 -0
- package/dist/cli.js +17 -1
- package/dist/client/index.js +42 -0
- package/dist/client/interface.d.ts +10 -1
- package/dist/daemon.d.ts +101 -1
- package/dist/daemon.js +208 -4
- package/dist/server.js +2 -0
- package/dist/tools/hosts.d.ts +4 -0
- package/dist/tools/hosts.js +40 -0
- package/dist/tools/recruit.d.ts +34 -1
- package/dist/tools/recruit.js +157 -1
- package/dist/tui/commands.js +27 -0
- package/dist/tui/index.js +1 -0
- package/dist/types.d.ts +84 -0
- package/dist/utils/format-hosts.d.ts +21 -0
- package/dist/utils/format-hosts.js +73 -0
- package/dist/utils/hosts.d.ts +84 -0
- package/dist/utils/hosts.js +228 -0
- package/dist/worker.js +29 -0
- package/dist/workflows/maestro-signals.d.ts +29 -1
- package/dist/workflows/maestro-signals.js +29 -1
- package/dist/workflows/maestro.js +43 -0
- package/package.json +1 -1
- package/workflow-bundle.js +73 -2
package/CLAUDE.md
CHANGED
|
@@ -70,6 +70,7 @@ src/
|
|
|
70
70
|
│ ├── load-lineup.ts / save-lineup.ts / agent-types.ts / resolve.ts
|
|
71
71
|
│ ├── set-name.ts / set-part.ts / who-am-i.ts / release.ts
|
|
72
72
|
│ ├── pause-ensemble.ts / resume-ensemble.ts
|
|
73
|
+
│ ├── hosts.ts
|
|
73
74
|
│ └── helpers.ts # Zod/MCP tool registration wrapper
|
|
74
75
|
├── tui/
|
|
75
76
|
│ ├── App.tsx / store.ts / commands.ts # TUI root, state, slash commands
|
|
@@ -78,6 +79,8 @@ src/
|
|
|
78
79
|
│ └── utils/ # format, platform, theme, fullscreen, history
|
|
79
80
|
├── utils/
|
|
80
81
|
│ ├── validation.ts / worktree.ts / safe-path.ts / duration.ts / search-attributes.ts
|
|
82
|
+
│ ├── attachment-format.ts / recall-format.ts # Shared display formatters (attachment-info, recall)
|
|
83
|
+
│ ├── hosts.ts / format-hosts.ts # Host enumeration + shared hosts display formatter (#274)
|
|
81
84
|
├── types.ts # Shared type definitions
|
|
82
85
|
├── git-info.ts # Git repository detection helper
|
|
83
86
|
└── config.ts # Env var handling
|
|
@@ -118,11 +121,12 @@ daemon worker notes, `npx ts-node` dev runner).
|
|
|
118
121
|
- **Outbox**: Outbound requests (cue, report, recruit, restart, detach, destroy, …) go through the session's workflow outbox instead of directly signaling other workflows. The dispatch loop processes entries via activities, decoupling tools from cross-workflow signaling.
|
|
119
122
|
- **Attachment phase** (v0.26): Seven phases tracked on the session workflow — `booting → attached → processing | awaiting → draining → detached → gone`. The phase is authoritative for lifecycle truth: adapters drive it via `claimAttachment` / `adapterExited` / `forceDetach` / `destroy`, and the workflow publishes it on the `ClaudeTempoAttachmentState` search attribute. Replaced the v0.25 `ClaudeTempoStatus` heuristic (removed in v0.26). See [docs/concepts.md](docs/concepts.md) for the phase table and [docs/ops/v0.26-migration.md](docs/ops/v0.26-migration.md) for the upgrade path.
|
|
120
123
|
- **Adapter heartbeat observability** (#249): After `claimAttachment`, the base adapter logs `first heartbeat scheduled in Xms` then `heartbeat#1 delivered` on the first tick. Every 10 ticks it emits `heartbeats-delivered=N / phase-ticks=N` breadcrumbs. Any silent guard trip in `tickHeartbeat` / `tickPhaseWatcher` now emits a structured `guard tripped: {stopped, reconnecting, …}` log instead of silently orphaning the timer. The phase-watcher emits `WARNING: heartbeat staleness` when `lastHeartbeatAt` falls more than 2× `heartbeatMs` behind `now`. Grep `[claude-tempo:adapter]` to confirm loop health without parsing Temporal history.
|
|
121
|
-
- **Per-host task queues**: `host` param on `recruit`/`restart`/`migrate` routes to `claude-tempo-{hostname}` task queue. See [docs/concepts.md](docs/concepts.md) for cross-machine recruiting details.
|
|
124
|
+
- **Per-host task queues**: `host` param on `recruit`/`restart`/`migrate` routes to `claude-tempo-{hostname}` task queue. When `host` is set on `recruit`, a pre-flight check validates the target daemon is live and supports the requested agent type (`force: true` bypasses). Daemon boot profiles (hostname, platform, available player types) are advertised via the `hostProfile` signal and maintained in the global maestro's `hostProfiles` map — surfaced by the `hosts` MCP tool, `claude-tempo hosts` CLI, and `/hosts` TUI command (#274). See [docs/concepts.md](docs/concepts.md) for cross-machine recruiting details.
|
|
122
125
|
- **Wire protocol**: All signal/query/update names are documented in [`docs/WIRE-PROTOCOL.md`](docs/WIRE-PROTOCOL.md) and are stable — renaming or removing any is a breaking change. **Process**: update `docs/WIRE-PROTOCOL.md` in the same commit as any new signal, query, or update.
|
|
123
126
|
- **Daemon**: Standalone background process (`src/daemon.ts`) that runs all Temporal workers. Auto-started by any `claude-tempo` command. PID at `~/.claude-tempo/daemon.pid`; logs at `~/.claude-tempo/daemon.log`.
|
|
124
127
|
- **Player types**: Reusable agent definitions in Claude Code's subagent format (`.md` files with YAML frontmatter). Three-tier lookup: project `.claude/agents/` → user `~/.claude/agents/` → shipped `examples/agents/`. Discover via `agent_types` tool or `claude-tempo agent-types` CLI. Shipped types: tempo-conductor, tempo-composer, tempo-soloist, tempo-tuner, tempo-critic, tempo-roadie, tempo-improv, tempo-liner.
|
|
125
128
|
- **Lineup examples**: Four pre-built ensemble YAML files in `examples/ensembles/` — `tempo-big-band`, `tempo-dev-team`, `tempo-review-squad`, `tempo-jam-session`. Load with `claude-tempo up --lineup <name>` or the `load_lineup` tool.
|
|
129
|
+
- **GitHub App identity** (`claude-tempo[bot]`): When a player writes to GitHub — issue comments, PR creation/merge, commits, labels, check runs — **use `./scripts/ensemble-gh`** instead of `gh`. The wrapper mints a short-lived installation token so the action is attributed to `claude-tempo[bot]`, not to the human maintainer, making the AI authorship visible. Plain `gh` is still correct for read-only local dev (`gh pr view`, `gh repo clone`, `gh auth status`). Every bot-authored comment/PR body must include the AI attribution footer documented in [docs/github-app.md](docs/github-app.md).
|
|
126
130
|
|
|
127
131
|
See [docs/concepts.md](docs/concepts.md) for the full glossary (Adapter, Attachment phases, Restart, Detach/Destroy, Migrate, Broadcast, Recall, Schedule, Lineup, Quality Gate, Worktree, Stage, Hold/Release, Pause/Resume, Maestro, TempoClient, and more).
|
|
128
132
|
|
package/README.md
CHANGED
|
@@ -143,6 +143,7 @@ claude-tempo up [ensemble] # first-time setup
|
|
|
143
143
|
claude-tempo conduct [ensemble] # start a conductor
|
|
144
144
|
claude-tempo start [ensemble] # start a player
|
|
145
145
|
claude-tempo status [ensemble] # list active sessions
|
|
146
|
+
claude-tempo hosts # list daemons polling this Temporal namespace (--all/--json)
|
|
146
147
|
claude-tempo recall <name> # read a player's message history (--limit/--offset/--preview/--from/--since/--include-sent/--json)
|
|
147
148
|
claude-tempo attachment-info <name> # inspect a session's phase, holder, lease, and heartbeat age
|
|
148
149
|
claude-tempo release [ensemble] # release held players (unlock + deliver tasks)
|
|
Binary file
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -106,6 +106,30 @@ export declare function detach(opts: DetachCliOpts): Promise<void>;
|
|
|
106
106
|
export declare function destroy(opts: DestroyCliOpts): Promise<void>;
|
|
107
107
|
export declare function migrate(opts: MigrateCliOpts): Promise<void>;
|
|
108
108
|
export declare function attachmentInfo(opts: VerbOpts): Promise<void>;
|
|
109
|
+
export interface HostsCliOpts extends CliOverrides {
|
|
110
|
+
ensemble?: string;
|
|
111
|
+
/** Include stale hosts (those not seen in the last minute). CLI default: false. */
|
|
112
|
+
all?: boolean;
|
|
113
|
+
/** Emit raw `HostInfo[]` JSON instead of the formatted table. */
|
|
114
|
+
json?: boolean;
|
|
115
|
+
}
|
|
116
|
+
export declare function hosts(opts: HostsCliOpts): Promise<void>;
|
|
117
|
+
export interface RefreshHostProfileCliOpts extends CliOverrides {
|
|
118
|
+
ensemble?: string;
|
|
119
|
+
/** Max wait for the new profile to appear in the maestro `hostProfiles` map. Default 10s. */
|
|
120
|
+
confirmTimeoutMs?: number;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* #274 AC5d (M12) — manual re-signal of this host's profile to the
|
|
124
|
+
* global maestro. The daemon otherwise only re-signals on boot; this
|
|
125
|
+
* subcommand re-computes the profile + signals fresh.
|
|
126
|
+
*
|
|
127
|
+
* Exit semantics (per my implementation-time call to the conductor,
|
|
128
|
+
* approved): await ensureGlobalMaestro → signal → short poll on
|
|
129
|
+
* `hostProfiles()` to confirm the new version is visible → exit 0.
|
|
130
|
+
* Exits nonzero if the poll timeout elapses without confirmation.
|
|
131
|
+
*/
|
|
132
|
+
export declare function refreshHostProfile(opts: RefreshHostProfileCliOpts): Promise<void>;
|
|
109
133
|
export interface RecallCliOpts extends VerbOpts {
|
|
110
134
|
limit?: number;
|
|
111
135
|
offset?: number;
|
package/dist/cli/commands.js
CHANGED
|
@@ -47,6 +47,8 @@ exports.detach = detach;
|
|
|
47
47
|
exports.destroy = destroy;
|
|
48
48
|
exports.migrate = migrate;
|
|
49
49
|
exports.attachmentInfo = attachmentInfo;
|
|
50
|
+
exports.hosts = hosts;
|
|
51
|
+
exports.refreshHostProfile = refreshHostProfile;
|
|
50
52
|
exports.recall = recall;
|
|
51
53
|
exports.restore = restore;
|
|
52
54
|
exports.ensembleCommand = ensembleCommand;
|
|
@@ -2128,6 +2130,83 @@ async function attachmentInfo(opts) {
|
|
|
2128
2130
|
await connection.close();
|
|
2129
2131
|
}
|
|
2130
2132
|
}
|
|
2133
|
+
async function hosts(opts) {
|
|
2134
|
+
const { config, connection, client } = await verbClient(opts);
|
|
2135
|
+
try {
|
|
2136
|
+
const { listHosts } = await Promise.resolve().then(() => __importStar(require('../utils/hosts')));
|
|
2137
|
+
const { formatHostList } = await Promise.resolve().then(() => __importStar(require('../utils/format-hosts')));
|
|
2138
|
+
const list = await listHosts(client, {
|
|
2139
|
+
force: true, // CLI always bypasses the cache — freshness expectation is "right now".
|
|
2140
|
+
namespace: config.temporalNamespace,
|
|
2141
|
+
taskQueue: config.taskQueue,
|
|
2142
|
+
});
|
|
2143
|
+
if (opts.json) {
|
|
2144
|
+
process.stdout.write(JSON.stringify(list, null, 2) + '\n');
|
|
2145
|
+
}
|
|
2146
|
+
else {
|
|
2147
|
+
out.log(formatHostList(list, { includeStale: opts.all }));
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
catch (err) {
|
|
2151
|
+
out.error(err?.message || String(err));
|
|
2152
|
+
process.exit(1);
|
|
2153
|
+
}
|
|
2154
|
+
finally {
|
|
2155
|
+
await connection.close();
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
/**
|
|
2159
|
+
* #274 AC5d (M12) — manual re-signal of this host's profile to the
|
|
2160
|
+
* global maestro. The daemon otherwise only re-signals on boot; this
|
|
2161
|
+
* subcommand re-computes the profile + signals fresh.
|
|
2162
|
+
*
|
|
2163
|
+
* Exit semantics (per my implementation-time call to the conductor,
|
|
2164
|
+
* approved): await ensureGlobalMaestro → signal → short poll on
|
|
2165
|
+
* `hostProfiles()` to confirm the new version is visible → exit 0.
|
|
2166
|
+
* Exits nonzero if the poll timeout elapses without confirmation.
|
|
2167
|
+
*/
|
|
2168
|
+
async function refreshHostProfile(opts) {
|
|
2169
|
+
const { config, connection, client } = await verbClient(opts);
|
|
2170
|
+
try {
|
|
2171
|
+
const { computeHostProfile, scrubHostProfile, advertiseHostProfile } = await Promise.resolve().then(() => __importStar(require('../daemon')));
|
|
2172
|
+
const { GLOBAL_MAESTRO_WORKFLOW_ID } = await Promise.resolve().then(() => __importStar(require('../config')));
|
|
2173
|
+
const profile = scrubHostProfile(computeHostProfile(config));
|
|
2174
|
+
const result = await advertiseHostProfile(client, profile, { log: (...a) => out.log(a.map(String).join(' ')) });
|
|
2175
|
+
if (!result.ok) {
|
|
2176
|
+
out.error(`hostProfile signal failed after ${result.attempts} attempts. Global Maestro may be unreachable.`);
|
|
2177
|
+
process.exit(1);
|
|
2178
|
+
}
|
|
2179
|
+
// Short confirmation poll — give the workflow a moment to apply the
|
|
2180
|
+
// signal and respond to the query with the fresh version. If the
|
|
2181
|
+
// maestro is absent entirely, the query will throw and we exit 1.
|
|
2182
|
+
const deadline = Date.now() + (opts.confirmTimeoutMs ?? 10_000);
|
|
2183
|
+
const target = profile.version;
|
|
2184
|
+
const handle = client.workflow.getHandle(GLOBAL_MAESTRO_WORKFLOW_ID);
|
|
2185
|
+
while (Date.now() < deadline) {
|
|
2186
|
+
try {
|
|
2187
|
+
const profiles = (await handle.query('hostProfiles'));
|
|
2188
|
+
const live = profiles[profile.hostname];
|
|
2189
|
+
if (live && live.version === target) {
|
|
2190
|
+
out.success(`Host profile for "${profile.hostname}" refreshed (version ${target}).`);
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
catch {
|
|
2195
|
+
// retry until deadline
|
|
2196
|
+
}
|
|
2197
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
2198
|
+
}
|
|
2199
|
+
out.error(`Signal sent but not yet reflected in hostProfiles() query after ${opts.confirmTimeoutMs ?? 10_000}ms. May succeed shortly; re-run to confirm.`);
|
|
2200
|
+
process.exit(1);
|
|
2201
|
+
}
|
|
2202
|
+
catch (err) {
|
|
2203
|
+
out.error(err?.message || String(err));
|
|
2204
|
+
process.exit(1);
|
|
2205
|
+
}
|
|
2206
|
+
finally {
|
|
2207
|
+
await connection.close();
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2131
2210
|
async function recall(opts) {
|
|
2132
2211
|
const { config, connection, client } = await verbClient(opts);
|
|
2133
2212
|
const ensemble = opts.ensemble || config.ensemble;
|
package/dist/cli/help-text.js
CHANGED
|
@@ -76,6 +76,8 @@ ${out.bold('Commands:')}
|
|
|
76
76
|
${out.cyan('migrate')} <name> --host Move a session to a different host
|
|
77
77
|
${out.cyan('attachment-info')} <name> Inspect the V2 attachment phase + current holder
|
|
78
78
|
${out.cyan('recall')} <name> Read a player's message history (--limit/--offset/--preview/--from/--since/--include-sent/--json)
|
|
79
|
+
${out.cyan('hosts')} List daemons polling this Temporal namespace with advertised capabilities (--all/--json)
|
|
80
|
+
${out.cyan('refresh-host-profile')} Re-advertise this daemon's capability profile to the global Maestro
|
|
79
81
|
${out.cyan('restore')} [name] Restore orphaned session(s) — interactive picker, or --all / --from-host / --dry-run
|
|
80
82
|
${out.cyan('release')} [ensemble] Release all held players (unlock outbox, deliver messages)
|
|
81
83
|
${out.cyan('pause')} [ensemble] Pause an ensemble (sessions, scheduler, maestro)
|
package/dist/cli.js
CHANGED
|
@@ -326,7 +326,7 @@ async function main() {
|
|
|
326
326
|
return;
|
|
327
327
|
}
|
|
328
328
|
// All other commands: lazy-load the full command surface now.
|
|
329
|
-
const { start, status, init, server, up, down, stop, ensembleCommand, agentTypesCommand, broadcast, release, pause, resume, restart, detach, destroy, migrate, attachmentInfo, recall, restore, } = await Promise.resolve().then(() => __importStar(require('./cli/commands')));
|
|
329
|
+
const { start, status, init, server, up, down, stop, ensembleCommand, agentTypesCommand, broadcast, release, pause, resume, restart, detach, destroy, migrate, attachmentInfo, recall, hosts, refreshHostProfile, restore, } = await Promise.resolve().then(() => __importStar(require('./cli/commands')));
|
|
330
330
|
const ensemble = args.positional[1] || process.env[config_1.ENV.ENSEMBLE] || 'default';
|
|
331
331
|
// Resolve the default agent from config (only needed for commands that use it)
|
|
332
332
|
const resolvedAgent = () => args.agent ?? (0, config_1.getConfig)(overrides).defaultAgent;
|
|
@@ -505,6 +505,22 @@ async function main() {
|
|
|
505
505
|
});
|
|
506
506
|
break;
|
|
507
507
|
}
|
|
508
|
+
case 'hosts': {
|
|
509
|
+
await hosts({
|
|
510
|
+
ensemble: args.ensemble || ensemble,
|
|
511
|
+
...(args.all ? { all: true } : {}),
|
|
512
|
+
...(args.json ? { json: true } : {}),
|
|
513
|
+
...overrides,
|
|
514
|
+
});
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
case 'refresh-host-profile': {
|
|
518
|
+
await refreshHostProfile({
|
|
519
|
+
ensemble: args.ensemble || ensemble,
|
|
520
|
+
...overrides,
|
|
521
|
+
});
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
508
524
|
case 'recall': {
|
|
509
525
|
// Positional player is required — matches the #128 design.
|
|
510
526
|
const name = args.positional[1] || args.name;
|
package/dist/client/index.js
CHANGED
|
@@ -1,4 +1,37 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.createTempoClient = createTempoClient;
|
|
4
37
|
/**
|
|
@@ -311,6 +344,15 @@ function createTempoClient(client) {
|
|
|
311
344
|
throw new Error(`No session found with name "${playerId}" in ensemble "${ensemble}".`);
|
|
312
345
|
return target.query(signals_1.attachmentInfoQuery);
|
|
313
346
|
},
|
|
347
|
+
async listHosts(opts = {}) {
|
|
348
|
+
// Lazy import so this doesn't drag utils/hosts into every
|
|
349
|
+
// consumer of TempoClient at module-load time.
|
|
350
|
+
const { listHosts } = await Promise.resolve().then(() => __importStar(require('../utils/hosts')));
|
|
351
|
+
return listHosts(client, {
|
|
352
|
+
force: Boolean(opts.force),
|
|
353
|
+
namespace: client.options.namespace,
|
|
354
|
+
});
|
|
355
|
+
},
|
|
314
356
|
async recall(ensemble, playerId) {
|
|
315
357
|
// #128: direct session queries, no maestro round-trip. Throws rather
|
|
316
358
|
// than returning empties so the CLI / TUI wrappers can surface a
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Extracted from `src/tui/client.ts` so that non-TUI consumers (CLI, tests,
|
|
5
5
|
* external integrations) can depend on the interface without pulling in Ink/React.
|
|
6
6
|
*/
|
|
7
|
-
import type { MaestroPlayerInfo, MaestroRelayMessage, HistoryEntry, Message, SentMessage, SessionMetadata, ScheduleEntry, QualityGate, StageEntry, WorktreeEntry, EnsembleChatResult, AttachmentInfo } from '../types';
|
|
7
|
+
import type { MaestroPlayerInfo, MaestroRelayMessage, HistoryEntry, Message, SentMessage, SessionMetadata, ScheduleEntry, QualityGate, StageEntry, WorktreeEntry, EnsembleChatResult, AttachmentInfo, HostInfo } from '../types';
|
|
8
8
|
/**
|
|
9
9
|
* Raw (unfiltered, unsorted, unsliced) output of `TempoClient.recall`.
|
|
10
10
|
* The shared formatter at `src/utils/recall-format.ts` turns this into a
|
|
@@ -79,6 +79,15 @@ export interface TempoClient {
|
|
|
79
79
|
* slice / render; the client stays presentation-free.
|
|
80
80
|
*/
|
|
81
81
|
recall(ensemble: string, playerId: string): Promise<RecallClientResult>;
|
|
82
|
+
/**
|
|
83
|
+
* #274: List all daemons polling this Temporal namespace, joined with
|
|
84
|
+
* their boot-signaled capability profiles. Consumers typically feed
|
|
85
|
+
* the result through `formatHostList` for a consistent UX across
|
|
86
|
+
* CLI / TUI / MCP. `force: true` bypasses the 3-second result cache.
|
|
87
|
+
*/
|
|
88
|
+
listHosts(opts?: {
|
|
89
|
+
force?: boolean;
|
|
90
|
+
}): Promise<HostInfo[]>;
|
|
82
91
|
/** Get active schedules for an ensemble. */
|
|
83
92
|
getSchedules(ensemble: string): Promise<ScheduleEntry[]>;
|
|
84
93
|
/** Cancel a named schedule in an ensemble. */
|
package/dist/daemon.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Client } from '@temporalio/client';
|
|
3
|
-
import { type DaemonConfig } from './config';
|
|
3
|
+
import { type Config, type DaemonConfig } from './config';
|
|
4
4
|
import { type OrphanCandidate } from './reconcile/orphans';
|
|
5
|
+
import type { HostProfile } from './types';
|
|
5
6
|
/**
|
|
6
7
|
* Atomically write the daemon PID file via `writeFile(tmp) + rename(tmp, final)`.
|
|
7
8
|
*
|
|
@@ -15,6 +16,105 @@ import { type OrphanCandidate } from './reconcile/orphans';
|
|
|
15
16
|
* Exported for unit testing.
|
|
16
17
|
*/
|
|
17
18
|
export declare function writePidFileAtomic(pidFilePath: string, pid: number): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* Build the daemon's capability profile from its config + runtime env.
|
|
21
|
+
* Result is NOT scrubbed — call `scrubHostProfile` before signaling.
|
|
22
|
+
*
|
|
23
|
+
* Exported for testability; production callers go through
|
|
24
|
+
* `runDaemonBoot(client, deps)` which provides this as the default
|
|
25
|
+
* `computeHostProfile` dep.
|
|
26
|
+
*/
|
|
27
|
+
export declare function computeHostProfile(config: Config): HostProfile;
|
|
28
|
+
/**
|
|
29
|
+
* #274 AC5c / M10 — HARD REQUIREMENT privacy scrub.
|
|
30
|
+
*
|
|
31
|
+
* Strips absolute paths and file extensions from every `HostProfile` field
|
|
32
|
+
* before the payload crosses the signal boundary. The global maestro is
|
|
33
|
+
* namespace-wide; a multi-tenant or multi-ensemble corporate setup would
|
|
34
|
+
* leak username-containing paths across ensembles if this is ever
|
|
35
|
+
* violated. Unit-tested in `test/daemon-boot.test.ts` with a dedicated
|
|
36
|
+
* "no `/` or `\\` in any string" invariant assertion against pathological
|
|
37
|
+
* inputs.
|
|
38
|
+
*
|
|
39
|
+
* Contract per architect AC5c:
|
|
40
|
+
* - `claudeBin` — basename only (e.g. `claude`), never absolute
|
|
41
|
+
* - `availableAgentTypes` — names only, never paths
|
|
42
|
+
* - `availablePlayerTypes` — names only, never paths
|
|
43
|
+
* - No env var values, no `workDir`, no user directories in any field
|
|
44
|
+
*
|
|
45
|
+
* The scrub is defense-in-depth: production callers (`computeHostProfile`)
|
|
46
|
+
* already produce clean inputs from `listAgentTypes().map(a => a.name)`.
|
|
47
|
+
* If a future code path accidentally passes a path, this function catches
|
|
48
|
+
* it before the workflow handler ever sees it.
|
|
49
|
+
*/
|
|
50
|
+
export declare function scrubHostProfile(raw: HostProfile): HostProfile;
|
|
51
|
+
/**
|
|
52
|
+
* Signal `hostProfile` with bounded retry (AC5b / M11).
|
|
53
|
+
*
|
|
54
|
+
* Default backoff: `[0, 5000, 15000]` ms → 3 attempts, ≤20 s wall-clock,
|
|
55
|
+
* well under the 30 s budget. Tests override to `[0, 0, 0]` for fast
|
|
56
|
+
* execution. On total failure, logs a warning and returns — the daemon
|
|
57
|
+
* stays alive without its profile advertised.
|
|
58
|
+
*
|
|
59
|
+
* Exported for reuse by the Phase 5 `claude-tempo refresh-host-profile`
|
|
60
|
+
* CLI subcommand, which re-signals without needing the full
|
|
61
|
+
* `runDaemonBoot` sequence (the global maestro is already up).
|
|
62
|
+
*/
|
|
63
|
+
export declare function advertiseHostProfile(client: Client, profile: HostProfile, opts?: {
|
|
64
|
+
retryBackoffsMs?: number[];
|
|
65
|
+
log?: (...args: unknown[]) => void;
|
|
66
|
+
sendSignal?: (client: Client, profile: HostProfile) => Promise<void>;
|
|
67
|
+
}): Promise<{
|
|
68
|
+
ok: boolean;
|
|
69
|
+
attempts: number;
|
|
70
|
+
lastError?: unknown;
|
|
71
|
+
}>;
|
|
72
|
+
/**
|
|
73
|
+
* #274 M14 — boot-sequence deps. Injected at `runDaemonBoot` so the
|
|
74
|
+
* ordering + retry + scrub invariants are all unit-testable without
|
|
75
|
+
* subprocess fixtures. Production callers pass the default impls
|
|
76
|
+
* already exported from this module.
|
|
77
|
+
*/
|
|
78
|
+
export interface DaemonBootDeps {
|
|
79
|
+
/** Ensure the global maestro workflow is running. Awaited before any signal. */
|
|
80
|
+
ensureGlobalMaestro: () => Promise<void>;
|
|
81
|
+
/** Signal the global maestro with the scrubbed host profile. */
|
|
82
|
+
sendHostProfileSignal: (client: Client, profile: HostProfile) => Promise<void>;
|
|
83
|
+
/**
|
|
84
|
+
* Compute the daemon's capability profile. Output is fed into
|
|
85
|
+
* `scrubHostProfile` before signaling — computeHostProfile itself does
|
|
86
|
+
* not need to scrub, the dep-swap pattern makes the pipeline testable.
|
|
87
|
+
*/
|
|
88
|
+
computeHostProfile: () => HostProfile;
|
|
89
|
+
/**
|
|
90
|
+
* Retry backoffs for the `hostProfile` signal (ms). Production uses
|
|
91
|
+
* `[0, 5000, 15000]`; tests override to `[0, 0, 0]` for speed.
|
|
92
|
+
*/
|
|
93
|
+
retryBackoffsMs?: number[];
|
|
94
|
+
/** Log sink. Tests stub to capture output. Defaults to module-level `log`. */
|
|
95
|
+
log?: (...args: unknown[]) => void;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* #274 M14 — daemon boot sequence: ensure global maestro is running,
|
|
99
|
+
* then advertise the (scrubbed) capability profile with bounded retry.
|
|
100
|
+
*
|
|
101
|
+
* Ordering is load-bearing (AC5a / M11): the `hostProfile` signal MUST
|
|
102
|
+
* NOT fire until `ensureGlobalMaestro` has resolved. Otherwise the
|
|
103
|
+
* signal races the workflow-start and gets silently dropped by Temporal
|
|
104
|
+
* (WorkflowNotFound on an unknown workflow id).
|
|
105
|
+
*
|
|
106
|
+
* Hard-failure behavior (AC5b): if `ensureGlobalMaestro` rejects, the
|
|
107
|
+
* daemon stays alive WITHOUT advertising its profile. Next opportunity
|
|
108
|
+
* is the next daemon restart OR a manual `claude-tempo refresh-host-profile`
|
|
109
|
+
* invocation (Phase 5).
|
|
110
|
+
*
|
|
111
|
+
* Tests in `test/daemon-boot.test.ts` exercise:
|
|
112
|
+
* - ensure-before-signal ordering via deferred promises
|
|
113
|
+
* - retry success on 3rd attempt
|
|
114
|
+
* - all-retries-exhausted stays alive
|
|
115
|
+
* - ensure-fails-stays-alive
|
|
116
|
+
*/
|
|
117
|
+
export declare function runDaemonBoot(client: Client, deps: DaemonBootDeps): Promise<void>;
|
|
18
118
|
/**
|
|
19
119
|
* PR-E reconcile-on-boot — design §10.1.
|
|
20
120
|
*
|
package/dist/daemon.js
CHANGED
|
@@ -35,6 +35,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
})();
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
37
|
exports.writePidFileAtomic = writePidFileAtomic;
|
|
38
|
+
exports.computeHostProfile = computeHostProfile;
|
|
39
|
+
exports.scrubHostProfile = scrubHostProfile;
|
|
40
|
+
exports.advertiseHostProfile = advertiseHostProfile;
|
|
41
|
+
exports.runDaemonBoot = runDaemonBoot;
|
|
38
42
|
exports.reconcileOnBoot = reconcileOnBoot;
|
|
39
43
|
exports.selectStaleDetachedOrphans = selectStaleDetachedOrphans;
|
|
40
44
|
exports.cleanupLoop = cleanupLoop;
|
|
@@ -50,6 +54,7 @@ exports.startCleanupLoop = startCleanupLoop;
|
|
|
50
54
|
*/
|
|
51
55
|
const fs = __importStar(require("fs"));
|
|
52
56
|
const os = __importStar(require("os"));
|
|
57
|
+
const path = __importStar(require("path"));
|
|
53
58
|
const promises_1 = require("timers/promises");
|
|
54
59
|
const client_1 = require("@temporalio/client");
|
|
55
60
|
const client_2 = require("@temporalio/client");
|
|
@@ -59,6 +64,7 @@ const connection_1 = require("./connection");
|
|
|
59
64
|
const daemon_1 = require("./cli/daemon");
|
|
60
65
|
const client_3 = require("./client");
|
|
61
66
|
const orphans_1 = require("./reconcile/orphans");
|
|
67
|
+
const agent_types_1 = require("./ensemble/agent-types");
|
|
62
68
|
const log = (...args) => console.error(`[claude-tempo:daemon ${new Date().toISOString()}]`, ...args);
|
|
63
69
|
/**
|
|
64
70
|
* Atomically write the daemon PID file via `writeFile(tmp) + rename(tmp, final)`.
|
|
@@ -121,6 +127,186 @@ async function ensureGlobalMaestro(config) {
|
|
|
121
127
|
log('Failed to ensure global Maestro (non-fatal):', err instanceof Error ? err.message : String(err));
|
|
122
128
|
}
|
|
123
129
|
}
|
|
130
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
131
|
+
// #274 — host capability profile: compute → scrub → signal
|
|
132
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
133
|
+
/**
|
|
134
|
+
* Daemon package version, lazily read from `package.json` so the test
|
|
135
|
+
* build (which compiles daemon.ts into `dist-test/src/` where
|
|
136
|
+
* `../package.json` doesn't resolve) can exercise daemon-boot logic
|
|
137
|
+
* without MODULE_NOT_FOUND. Tests that exercise `computeHostProfile`
|
|
138
|
+
* pass a stubbed version via `HostProfile.version` on the input.
|
|
139
|
+
*/
|
|
140
|
+
function daemonVersion() {
|
|
141
|
+
try {
|
|
142
|
+
const { version } = require('../package.json');
|
|
143
|
+
return version;
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return 'unknown';
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Build the daemon's capability profile from its config + runtime env.
|
|
151
|
+
* Result is NOT scrubbed — call `scrubHostProfile` before signaling.
|
|
152
|
+
*
|
|
153
|
+
* Exported for testability; production callers go through
|
|
154
|
+
* `runDaemonBoot(client, deps)` which provides this as the default
|
|
155
|
+
* `computeHostProfile` dep.
|
|
156
|
+
*/
|
|
157
|
+
function computeHostProfile(config) {
|
|
158
|
+
const agentTypes = (() => {
|
|
159
|
+
try {
|
|
160
|
+
return (0, agent_types_1.listAgentTypes)().map((a) => a.name);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// listAgentTypes reads the filesystem; treat any failure as "no
|
|
164
|
+
// discoverable types" rather than crashing boot.
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
})();
|
|
168
|
+
return {
|
|
169
|
+
hostname: os.hostname(),
|
|
170
|
+
version: daemonVersion(),
|
|
171
|
+
defaultAgent: config.defaultAgent,
|
|
172
|
+
// Currently the daemon advertises only the configured default as an
|
|
173
|
+
// "available runtime"; future work can probe for Copilot bridge via
|
|
174
|
+
// `require.resolve('@github/copilot-sdk')`. Recording as an array
|
|
175
|
+
// keeps the wire shape forward-compatible.
|
|
176
|
+
availableAgentTypes: [config.defaultAgent],
|
|
177
|
+
availablePlayerTypes: agentTypes,
|
|
178
|
+
claudeBin: config.claudeBin,
|
|
179
|
+
platform: process.platform,
|
|
180
|
+
capabilities: [],
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* #274 AC5c / M10 — HARD REQUIREMENT privacy scrub.
|
|
185
|
+
*
|
|
186
|
+
* Strips absolute paths and file extensions from every `HostProfile` field
|
|
187
|
+
* before the payload crosses the signal boundary. The global maestro is
|
|
188
|
+
* namespace-wide; a multi-tenant or multi-ensemble corporate setup would
|
|
189
|
+
* leak username-containing paths across ensembles if this is ever
|
|
190
|
+
* violated. Unit-tested in `test/daemon-boot.test.ts` with a dedicated
|
|
191
|
+
* "no `/` or `\\` in any string" invariant assertion against pathological
|
|
192
|
+
* inputs.
|
|
193
|
+
*
|
|
194
|
+
* Contract per architect AC5c:
|
|
195
|
+
* - `claudeBin` — basename only (e.g. `claude`), never absolute
|
|
196
|
+
* - `availableAgentTypes` — names only, never paths
|
|
197
|
+
* - `availablePlayerTypes` — names only, never paths
|
|
198
|
+
* - No env var values, no `workDir`, no user directories in any field
|
|
199
|
+
*
|
|
200
|
+
* The scrub is defense-in-depth: production callers (`computeHostProfile`)
|
|
201
|
+
* already produce clean inputs from `listAgentTypes().map(a => a.name)`.
|
|
202
|
+
* If a future code path accidentally passes a path, this function catches
|
|
203
|
+
* it before the workflow handler ever sees it.
|
|
204
|
+
*/
|
|
205
|
+
function scrubHostProfile(raw) {
|
|
206
|
+
const stripPath = (s) => {
|
|
207
|
+
// Platform-independent basename: `path.basename` is runtime-bound —
|
|
208
|
+
// on POSIX it doesn't recognise `\` as a separator, so a Windows
|
|
209
|
+
// daemon's signal leaking `'C:\Users\alice\bin\claude.exe'` into
|
|
210
|
+
// a Linux-hosted global maestro would bypass the scrub entirely
|
|
211
|
+
// (CI caught exactly this on Ubuntu shard-2). Normalize first,
|
|
212
|
+
// then use `path.posix.basename` explicitly so the scrub is
|
|
213
|
+
// deterministic regardless of where the daemon or maestro runs.
|
|
214
|
+
//
|
|
215
|
+
// Also strip a single trailing `.md` — player-type files are
|
|
216
|
+
// shipped as e.g. `tempo-soloist.md` but the name should be just
|
|
217
|
+
// `tempo-soloist` on the wire.
|
|
218
|
+
const normalized = s.replace(/\\/g, '/');
|
|
219
|
+
const base = path.posix.basename(normalized);
|
|
220
|
+
return base.endsWith('.md') ? base.slice(0, -3) : base;
|
|
221
|
+
};
|
|
222
|
+
const scrubList = (list) => list?.map(stripPath);
|
|
223
|
+
return {
|
|
224
|
+
hostname: raw.hostname,
|
|
225
|
+
version: raw.version,
|
|
226
|
+
defaultAgent: raw.defaultAgent,
|
|
227
|
+
availableAgentTypes: scrubList(raw.availableAgentTypes),
|
|
228
|
+
availablePlayerTypes: scrubList(raw.availablePlayerTypes),
|
|
229
|
+
claudeBin: raw.claudeBin ? stripPath(raw.claudeBin) : undefined,
|
|
230
|
+
platform: raw.platform,
|
|
231
|
+
capabilities: raw.capabilities,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/** Production default: signal the global maestro with the profile. */
|
|
235
|
+
async function realSendHostProfileSignal(client, profile) {
|
|
236
|
+
const handle = client.workflow.getHandle(config_1.GLOBAL_MAESTRO_WORKFLOW_ID);
|
|
237
|
+
await handle.signal('hostProfile', profile);
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Signal `hostProfile` with bounded retry (AC5b / M11).
|
|
241
|
+
*
|
|
242
|
+
* Default backoff: `[0, 5000, 15000]` ms → 3 attempts, ≤20 s wall-clock,
|
|
243
|
+
* well under the 30 s budget. Tests override to `[0, 0, 0]` for fast
|
|
244
|
+
* execution. On total failure, logs a warning and returns — the daemon
|
|
245
|
+
* stays alive without its profile advertised.
|
|
246
|
+
*
|
|
247
|
+
* Exported for reuse by the Phase 5 `claude-tempo refresh-host-profile`
|
|
248
|
+
* CLI subcommand, which re-signals without needing the full
|
|
249
|
+
* `runDaemonBoot` sequence (the global maestro is already up).
|
|
250
|
+
*/
|
|
251
|
+
async function advertiseHostProfile(client, profile, opts = {}) {
|
|
252
|
+
const backoffs = opts.retryBackoffsMs ?? [0, 5000, 15000];
|
|
253
|
+
const logFn = opts.log ?? log;
|
|
254
|
+
const send = opts.sendSignal ?? realSendHostProfileSignal;
|
|
255
|
+
let lastError;
|
|
256
|
+
for (let attempt = 0; attempt < backoffs.length; attempt++) {
|
|
257
|
+
const delay = backoffs[attempt];
|
|
258
|
+
if (delay > 0)
|
|
259
|
+
await (0, promises_1.setTimeout)(delay);
|
|
260
|
+
try {
|
|
261
|
+
await send(client, profile);
|
|
262
|
+
logFn(`Advertised host profile for "${profile.hostname}" (attempt ${attempt + 1}/${backoffs.length})`);
|
|
263
|
+
return { ok: true, attempts: attempt + 1 };
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
lastError = err;
|
|
267
|
+
logFn(`hostProfile signal attempt ${attempt + 1}/${backoffs.length} failed:`, err instanceof Error ? err.message : err);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
logFn(`Failed to advertise host profile after ${backoffs.length} attempts (non-fatal; daemon stays alive):`, lastError instanceof Error ? lastError.message : lastError);
|
|
271
|
+
return { ok: false, attempts: backoffs.length, lastError };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* #274 M14 — daemon boot sequence: ensure global maestro is running,
|
|
275
|
+
* then advertise the (scrubbed) capability profile with bounded retry.
|
|
276
|
+
*
|
|
277
|
+
* Ordering is load-bearing (AC5a / M11): the `hostProfile` signal MUST
|
|
278
|
+
* NOT fire until `ensureGlobalMaestro` has resolved. Otherwise the
|
|
279
|
+
* signal races the workflow-start and gets silently dropped by Temporal
|
|
280
|
+
* (WorkflowNotFound on an unknown workflow id).
|
|
281
|
+
*
|
|
282
|
+
* Hard-failure behavior (AC5b): if `ensureGlobalMaestro` rejects, the
|
|
283
|
+
* daemon stays alive WITHOUT advertising its profile. Next opportunity
|
|
284
|
+
* is the next daemon restart OR a manual `claude-tempo refresh-host-profile`
|
|
285
|
+
* invocation (Phase 5).
|
|
286
|
+
*
|
|
287
|
+
* Tests in `test/daemon-boot.test.ts` exercise:
|
|
288
|
+
* - ensure-before-signal ordering via deferred promises
|
|
289
|
+
* - retry success on 3rd attempt
|
|
290
|
+
* - all-retries-exhausted stays alive
|
|
291
|
+
* - ensure-fails-stays-alive
|
|
292
|
+
*/
|
|
293
|
+
async function runDaemonBoot(client, deps) {
|
|
294
|
+
const logFn = deps.log ?? log;
|
|
295
|
+
const raw = deps.computeHostProfile();
|
|
296
|
+
const profile = scrubHostProfile(raw);
|
|
297
|
+
try {
|
|
298
|
+
await deps.ensureGlobalMaestro();
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
logFn('ensureGlobalMaestro failed (non-fatal); host profile not advertised this boot:', err instanceof Error ? err.message : err);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
await advertiseHostProfile(client, profile, {
|
|
305
|
+
retryBackoffsMs: deps.retryBackoffsMs,
|
|
306
|
+
log: logFn,
|
|
307
|
+
sendSignal: deps.sendHostProfileSignal,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
124
310
|
// ── Reconcile-on-boot (PR-E §10.1) ──
|
|
125
311
|
/**
|
|
126
312
|
* PR-E reconcile-on-boot — design §10.1.
|
|
@@ -404,10 +590,28 @@ async function main() {
|
|
|
404
590
|
sharedWorker = workers.sharedWorker;
|
|
405
591
|
hostWorker = workers.hostWorker;
|
|
406
592
|
log('Workers created — processing tasks');
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
593
|
+
// #274 — daemon boot sequence: ensure the global maestro is running,
|
|
594
|
+
// then advertise this host's capability profile with bounded retry.
|
|
595
|
+
// Fire-and-forget from main's perspective (the workers above are
|
|
596
|
+
// already polling tasks; we don't block the run loop on maestro
|
|
597
|
+
// ensure + profile signaling). Ordering INSIDE runDaemonBoot is
|
|
598
|
+
// load-bearing — see M11 / AC5a — so the outer `.catch` only
|
|
599
|
+
// handles unexpected throws (both ensure and signal paths log +
|
|
600
|
+
// return gracefully on their own).
|
|
601
|
+
(async () => {
|
|
602
|
+
try {
|
|
603
|
+
const bootConnection = await (0, connection_1.createTemporalConnection)(config);
|
|
604
|
+
const bootClient = new client_1.Client({ connection: bootConnection, namespace: config.temporalNamespace });
|
|
605
|
+
await runDaemonBoot(bootClient, {
|
|
606
|
+
ensureGlobalMaestro: () => ensureGlobalMaestro(config),
|
|
607
|
+
sendHostProfileSignal: realSendHostProfileSignal,
|
|
608
|
+
computeHostProfile: () => computeHostProfile(config),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
catch (err) {
|
|
612
|
+
log('runDaemonBoot background error:', err);
|
|
613
|
+
}
|
|
614
|
+
})();
|
|
411
615
|
// PR-E reconcile-on-boot + cleanup loop (design §10, §13.4). Both run
|
|
412
616
|
// against their own Temporal Client, not the worker connection — they
|
|
413
617
|
// call `workflow.list` + `workflow.getHandle().query(...)` which are
|