@cohortapp/agent-sdk 2.11.15 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +117 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +313 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/design.mjs +185 -0
- package/lib/cli/design.test.mjs +270 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +454 -0
- package/lib/cli/global-setup-extras.test.mjs +462 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +760 -0
- package/lib/cli/session.test.mjs +613 -0
- package/lib/collective/global-config.mjs +209 -6
- package/lib/collective/global-config.test.mjs +145 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/collective/vendor-skills.mjs +305 -0
- package/lib/collective/vendor-skills.test.mjs +306 -0
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/design/design-md.mjs +793 -0
- package/lib/design/design-md.test.mjs +318 -0
- package/lib/design/fixtures/DESIGN.golden.md +238 -0
- package/lib/design/fixtures/PRODUCT.golden.md +67 -0
- package/lib/design/fixtures/foundation.json +133 -0
- package/lib/design/refresh-gate.mjs +154 -0
- package/lib/design/refresh-gate.test.mjs +144 -0
- package/lib/design/write.mjs +275 -0
- package/lib/design/write.test.mjs +241 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/prompts/parallelism.mjs +79 -0
- package/lib/prompts/parallelism.test.mjs +177 -0
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +25 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/cohort-design.md +153 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
- package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
- package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
- package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
- package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
- package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
- package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
- package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
- package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
- package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
- package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
- package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
- package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
- package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
- package/scaffold/CLAUDE.md +24 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check-skill-packs.mjs +388 -0
- package/scripts/ci/check-skill-packs.test.mjs +495 -0
- package/scripts/ci/check.mjs +6 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +249 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +393 -0
- package/scripts/daemon/cadence-consumer.mjs +289 -89
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/prompt-builder.mjs +19 -3
- package/scripts/daemon/responder.mjs +51 -40
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +518 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/vendor/skill-packs.mjs +354 -0
- package/scripts/vendor/sync-skill-packs.mjs +242 -0
- package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dispatcher-resume.test.mjs — audit F11: `defaultSpawnResume` built a BARE
|
|
3
|
+
* env (no router, no scrub) and a bare argv (no --output-format json, no
|
|
4
|
+
* knobs). A session originally routed to a third-party backend therefore
|
|
5
|
+
* resumed against api.anthropic.com with a foreign model id, and every
|
|
6
|
+
* resumed run recorded $0 because nothing parsed its usage.
|
|
7
|
+
*
|
|
8
|
+
* The resume now goes through the SAME resolveSpawnTarget / buildChildEnv
|
|
9
|
+
* path as a fresh spawnSession. The pure builder is tested directly; the
|
|
10
|
+
* real defaultSpawnResume is exercised through setSpawnForTests so the argv
|
|
11
|
+
* and env handed to `claude` are asserted byte-for-byte.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { test } from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { promises as fsp } from "node:fs";
|
|
17
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
async function freshDispatcher() {
|
|
22
|
+
const dir = join(tmpdir(), `dispatcher-resume-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
23
|
+
mkdirSync(join(dir, "state/sessions"), { recursive: true });
|
|
24
|
+
mkdirSync(join(dir, "logs/daemon"), { recursive: true });
|
|
25
|
+
process.env.AGENT_DIR = dir;
|
|
26
|
+
const url = new URL("./dispatcher.mjs", import.meta.url);
|
|
27
|
+
const mod = await import(`${url.href}?resume=${Math.random()}`);
|
|
28
|
+
return { mod, dir };
|
|
29
|
+
}
|
|
30
|
+
async function cleanup(dir) { try { await fsp.rm(dir, { recursive: true, force: true }); } catch { /* */ } }
|
|
31
|
+
function plantMarker(dir, sessionId, marker) {
|
|
32
|
+
const d = join(dir, "state/sessions/resume-pending");
|
|
33
|
+
mkdirSync(d, { recursive: true });
|
|
34
|
+
writeFileSync(join(d, `${sessionId}.json`), JSON.stringify({
|
|
35
|
+
sessionId, claudeSessionId: `cs-${sessionId}`, source: "backlog", itemId: "BL-1", sourceFile: "q.yaml",
|
|
36
|
+
prompt: "finish the memo", modelFlag: "claude-opus-4-6", model: "opus", priority: "normal",
|
|
37
|
+
startedAt: Date.now(), recoveryAttempts: 0, ...marker,
|
|
38
|
+
}, null, 2));
|
|
39
|
+
}
|
|
40
|
+
function writeV2Config(dir) {
|
|
41
|
+
mkdirSync(join(dir, "config"), { recursive: true });
|
|
42
|
+
writeFileSync(join(dir, "config/model-routing.json"), JSON.stringify({
|
|
43
|
+
schema_version: 2,
|
|
44
|
+
aliases: { frontier: "anthropic/claude-opus-4-8", default: "anthropic/claude-sonnet-4-6", fast: "anthropic/claude-haiku-4-5" },
|
|
45
|
+
defaults: { needs_tool_use_for_sessions: true, data_class: "sensitive", cache_ttl: "1h" },
|
|
46
|
+
backends: { anthropic: { allowed_data_classes: ["public", "internal", "sensitive"] } },
|
|
47
|
+
routing_policy: [{ default: true, chain: ["default", "fast"] }],
|
|
48
|
+
fallback_to_anthropic: true,
|
|
49
|
+
}, null, 2));
|
|
50
|
+
}
|
|
51
|
+
function fakeProc() {
|
|
52
|
+
const handlers = {};
|
|
53
|
+
return { stdout: { on: () => {} }, stderr: { on: () => {} }, on: (ev, cb) => { handlers[ev] = cb; }, kill: () => {}, killed: false, _handlers: handlers };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ANTHROPIC_TARGET = { modelFlag: "claude-sonnet-4-6", envForSpawn: {}, decisionId: "d-1", backend: "anthropic", transport: "anthropic-cli", maxTurns: 40, effort: null, agentsJson: { "Haiku-Explore": { model: "haiku" } } };
|
|
57
|
+
const RETARGET = { modelFlag: "kimi-k2", envForSpawn: { ANTHROPIC_BASE_URL: "https://moon.example", ANTHROPIC_AUTH_TOKEN: "tok-xyz", ANTHROPIC_API_KEY: "" }, decisionId: "d-2", backend: "moonshot", transport: "anthropic-compatible", maxTurns: 40, effort: "high", agentsJson: null };
|
|
58
|
+
|
|
59
|
+
test("buildResumeSpawn (Anthropic): --print --output-format json + knobs + --session-id + marker model + prompt last; legacy key strip; session id in env", async () => {
|
|
60
|
+
const { mod, dir } = await freshDispatcher();
|
|
61
|
+
try {
|
|
62
|
+
const marker = { sessionId: "s-1", claudeSessionId: "CS-1", prompt: "finish the memo", modelFlag: "claude-opus-4-6", model: "opus" };
|
|
63
|
+
const { args, env, modelFlag } = mod.buildResumeSpawn({
|
|
64
|
+
marker, target: ANTHROPIC_TARGET,
|
|
65
|
+
baseEnv: { PATH: "/usr/bin", ANTHROPIC_API_KEY: "sk-live", ANTHROPIC_AUTH_TOKEN: "at", FOO_API_KEY: "foo", CLAUDE_CODE_OAUTH_TOKEN: "oauth-1" },
|
|
66
|
+
permissionArgs: ["--dangerously-skip-permissions"], daemonArgs: ["--strict-mcp-config"], augmentedPath: () => "/aug",
|
|
67
|
+
});
|
|
68
|
+
assert.equal(args[0], "--print");
|
|
69
|
+
assert.equal(args[args.indexOf("--output-format") + 1], "json", "usage is measurable on a resumed run");
|
|
70
|
+
assert.ok(args.includes("--dangerously-skip-permissions"));
|
|
71
|
+
assert.ok(args.includes("--strict-mcp-config"));
|
|
72
|
+
assert.equal(args[args.indexOf("--max-turns") + 1], "40", "the router's knob args ride the resume");
|
|
73
|
+
assert.ok(/Haiku-Explore/.test(args[args.indexOf("--agents") + 1]));
|
|
74
|
+
assert.equal(args[args.indexOf("--session-id") + 1], "CS-1");
|
|
75
|
+
assert.equal(args[args.indexOf("--model") + 1], "claude-opus-4-6", "an Anthropic session resumes on the exact flag it used");
|
|
76
|
+
assert.equal(modelFlag, "claude-opus-4-6");
|
|
77
|
+
assert.equal(args[args.length - 1], "finish the memo");
|
|
78
|
+
assert.ok(!args.includes("--resume"));
|
|
79
|
+
// Env: the non-retarget path keeps the historical explicit-empty-key posture (keychain/OAuth fallthrough).
|
|
80
|
+
assert.equal(env.ANTHROPIC_API_KEY, "");
|
|
81
|
+
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "");
|
|
82
|
+
assert.equal(env.PATH, "/aug");
|
|
83
|
+
assert.equal(env.MAESTRO_SESSION_ID, "s-1", "delivery receipts attribute to the resumed session");
|
|
84
|
+
assert.equal(env.CLAUDE_CODE_OAUTH_TOKEN, "oauth-1", "the subscription token reaches the resumed session");
|
|
85
|
+
} finally { await cleanup(dir); }
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("buildResumeSpawn (retarget): env goes through buildChildEnv — base URL + token injected, foreign credentials scrubbed", async () => {
|
|
89
|
+
const { mod, dir } = await freshDispatcher();
|
|
90
|
+
try {
|
|
91
|
+
const marker = { sessionId: "s-2", claudeSessionId: "CS-2", prompt: "p", modelFlag: "kimi-k2", model: "sonnet" };
|
|
92
|
+
const { args, env } = mod.buildResumeSpawn({
|
|
93
|
+
marker, target: RETARGET,
|
|
94
|
+
baseEnv: { PATH: "/usr/bin", ANTHROPIC_API_KEY: "sk-live", FOO_API_KEY: "foo", HOME: "/h" },
|
|
95
|
+
permissionArgs: [], daemonArgs: [], augmentedPath: () => "/aug",
|
|
96
|
+
});
|
|
97
|
+
assert.equal(env.ANTHROPIC_BASE_URL, "https://moon.example", "the resumed session is retargeted like the original");
|
|
98
|
+
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "tok-xyz");
|
|
99
|
+
assert.equal(env.ANTHROPIC_API_KEY, "", "empty-string discipline: no keychain fallthrough on a retarget");
|
|
100
|
+
assert.equal(env.FOO_API_KEY, undefined, "§7.3 scrub: no foreign credential leaks");
|
|
101
|
+
assert.equal(env.HOME, "/h");
|
|
102
|
+
assert.equal(env.PATH, "/aug");
|
|
103
|
+
assert.equal(env.MAESTRO_SESSION_ID, "s-2");
|
|
104
|
+
assert.equal(args[args.indexOf("--model") + 1], "kimi-k2");
|
|
105
|
+
assert.equal(args[args.indexOf("--effort") + 1], "high");
|
|
106
|
+
} finally { await cleanup(dir); }
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("resumeModelFlag: a foreign model id never resumes against Anthropic (F11) — the router's current flag wins", async () => {
|
|
110
|
+
const { mod, dir } = await freshDispatcher();
|
|
111
|
+
try {
|
|
112
|
+
// Original ran on a third party; the router now resolves Anthropic (provider down / config changed).
|
|
113
|
+
assert.equal(mod.resumeModelFlag({ modelFlag: "moonshot/kimi-k2" }, ANTHROPIC_TARGET), "claude-sonnet-4-6");
|
|
114
|
+
assert.equal(mod.resumeModelFlag({ modelFlag: "kimi-k2" }, ANTHROPIC_TARGET), "claude-sonnet-4-6");
|
|
115
|
+
// Original ran on Anthropic; still Anthropic → the exact flag it used (H1 pin).
|
|
116
|
+
assert.equal(mod.resumeModelFlag({ modelFlag: "claude-opus-4-6" }, ANTHROPIC_TARGET), "claude-opus-4-6");
|
|
117
|
+
assert.equal(mod.resumeModelFlag({ modelFlag: "opus" }, ANTHROPIC_TARGET), "opus");
|
|
118
|
+
// A retarget: the router's flag for that backend.
|
|
119
|
+
assert.equal(mod.resumeModelFlag({ modelFlag: "claude-opus-4-6" }, RETARGET), "kimi-k2");
|
|
120
|
+
// No router at all (target null) → marker flag, then coarse class, then sonnet.
|
|
121
|
+
assert.equal(mod.resumeModelFlag({ modelFlag: "claude-opus-4-6" }, null), "claude-opus-4-6");
|
|
122
|
+
assert.equal(mod.resumeModelFlag({ model: "opus" }, null), "opus");
|
|
123
|
+
assert.equal(mod.resumeModelFlag({}, null), "sonnet");
|
|
124
|
+
} finally { await cleanup(dir); }
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("defaultSpawnResume (real path, v2 config): argv carries --output-format json + router knobs; env carries the session id and the key strip", async () => {
|
|
128
|
+
const { mod, dir } = await freshDispatcher();
|
|
129
|
+
try {
|
|
130
|
+
writeV2Config(dir);
|
|
131
|
+
plantMarker(dir, "s-real", { claudeSessionId: "CS-FIXED", prompt: "finish drafting the board memo", modelFlag: "claude-opus-4-6" });
|
|
132
|
+
const calls = [];
|
|
133
|
+
const restore = mod.setSpawnForTests((bin, args, opts) => { calls.push({ bin, args, opts }); return fakeProc(); });
|
|
134
|
+
try {
|
|
135
|
+
const stats = mod.reconcileResumePending();
|
|
136
|
+
assert.equal(stats.resumed, 1);
|
|
137
|
+
} finally { restore(); }
|
|
138
|
+
assert.equal(calls.length, 1);
|
|
139
|
+
const { args, opts } = calls[0];
|
|
140
|
+
assert.equal(args[0], "--print");
|
|
141
|
+
assert.equal(args[args.indexOf("--output-format") + 1], "json");
|
|
142
|
+
assert.equal(args[args.indexOf("--max-turns") + 1], "40", "v2 knobs are no longer dropped on resume");
|
|
143
|
+
assert.ok(args.includes("--agents"));
|
|
144
|
+
assert.equal(args[args.indexOf("--session-id") + 1], "CS-FIXED");
|
|
145
|
+
assert.equal(args[args.indexOf("--model") + 1], "claude-opus-4-6");
|
|
146
|
+
assert.equal(args[args.length - 1], "finish drafting the board memo");
|
|
147
|
+
assert.ok(!args.includes("--resume"));
|
|
148
|
+
assert.equal(opts.env.MAESTRO_SESSION_ID, "s-real");
|
|
149
|
+
assert.equal(opts.env.ANTHROPIC_API_KEY, "");
|
|
150
|
+
assert.equal(opts.cwd, dir);
|
|
151
|
+
} finally { await cleanup(dir); }
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("defaultSpawnResume (no routing config): still a --print --output-format json spawn on the marker's flag", async () => {
|
|
155
|
+
const { mod, dir } = await freshDispatcher();
|
|
156
|
+
try {
|
|
157
|
+
plantMarker(dir, "s-plain", { claudeSessionId: "CS-P", prompt: "p", modelFlag: "claude-sonnet-4-6" });
|
|
158
|
+
const calls = [];
|
|
159
|
+
const restore = mod.setSpawnForTests((bin, args, opts) => { calls.push({ args, opts }); return fakeProc(); });
|
|
160
|
+
try { mod.reconcileResumePending(); } finally { restore(); }
|
|
161
|
+
const { args } = calls[0];
|
|
162
|
+
assert.equal(args[args.indexOf("--output-format") + 1], "json");
|
|
163
|
+
assert.equal(args[args.indexOf("--model") + 1], "claude-sonnet-4-6");
|
|
164
|
+
assert.ok(!args.includes("--max-turns"), "no router → no knobs (unchanged)");
|
|
165
|
+
} finally { await cleanup(dir); }
|
|
166
|
+
});
|
|
@@ -610,6 +610,93 @@ export function reconcileResumePending(opts = {}) {
|
|
|
610
610
|
return stats;
|
|
611
611
|
}
|
|
612
612
|
|
|
613
|
+
/**
|
|
614
|
+
* Does a `--model` flag name an Anthropic model (a catalog id or a CLI alias)?
|
|
615
|
+
* A third-party id (`moonshot/kimi-k2`, `kimi-k2`) is anything else.
|
|
616
|
+
*/
|
|
617
|
+
function isAnthropicModelFlag(flag) {
|
|
618
|
+
const f = String(flag || "");
|
|
619
|
+
return !!f && !f.includes("/") && !f.includes(":") && /claude|sonnet|opus|haiku/i.test(f);
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Which `--model` a resume passes (audit F11). PURE.
|
|
624
|
+
*
|
|
625
|
+
* The original spawn's exact flag is pinned in the marker (H1) so an
|
|
626
|
+
* Anthropic session resumes on the model it used. But a flag is only
|
|
627
|
+
* meaningful on the backend that minted it: a marker from a third-party
|
|
628
|
+
* session whose router decision has since moved to Anthropic (provider down,
|
|
629
|
+
* config changed) must NOT hand Anthropic a foreign id — that was the F11
|
|
630
|
+
* failure. So: a retarget takes the router's flag for THAT backend; an
|
|
631
|
+
* Anthropic target keeps the marker's flag only when it is Anthropic-shaped;
|
|
632
|
+
* no router at all keeps the historical marker → class → sonnet ladder.
|
|
633
|
+
*
|
|
634
|
+
* @param {object} marker the resume-pending marker
|
|
635
|
+
* @param {object|null} target resolveSpawnTarget() output, or null (no router)
|
|
636
|
+
* @returns {string}
|
|
637
|
+
*/
|
|
638
|
+
export function resumeModelFlag(marker, target) {
|
|
639
|
+
const m = marker && typeof marker === "object" ? marker : {};
|
|
640
|
+
const pinned = m.modelFlag || m.model || null;
|
|
641
|
+
if (!target || typeof target !== "object") return pinned || "sonnet";
|
|
642
|
+
const retargeting = !!(target.envForSpawn && target.envForSpawn.ANTHROPIC_BASE_URL);
|
|
643
|
+
if (retargeting) return target.modelFlag || pinned || "sonnet";
|
|
644
|
+
if (pinned && isAnthropicModelFlag(pinned)) return pinned;
|
|
645
|
+
return target.modelFlag || (pinned && isAnthropicModelFlag(pinned) ? pinned : null) || m.model || "sonnet";
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Build the argv + child env for a resume. PURE — every input injected, so
|
|
650
|
+
* the exact bytes handed to `claude` are testable. Mirrors spawnSession:
|
|
651
|
+
*
|
|
652
|
+
* [--print, --output-format json, <permission args>, <daemon args>,
|
|
653
|
+
* <router knobs>, --session-id <id>, --model <flag>, <prompt>]
|
|
654
|
+
*
|
|
655
|
+
* and the same env rule: a retarget (envForSpawn carries ANTHROPIC_BASE_URL)
|
|
656
|
+
* goes through buildChildEnv's §7.3 credential scrub; the Anthropic path keeps
|
|
657
|
+
* the explicit empty-key posture (keychain / OAuth-token fallthrough).
|
|
658
|
+
* MAESTRO_SESSION_ID rides the env so delivery receipts attribute correctly.
|
|
659
|
+
*
|
|
660
|
+
* @param {object} o
|
|
661
|
+
* @param {object} o.marker
|
|
662
|
+
* @param {object|null} o.target resolveSpawnTarget() output or null
|
|
663
|
+
* @param {object} o.baseEnv
|
|
664
|
+
* @param {string[]} [o.permissionArgs]
|
|
665
|
+
* @param {string[]} [o.daemonArgs]
|
|
666
|
+
* @param {Function} [o.augmentedPath]
|
|
667
|
+
* @returns {{args:string[], env:object, modelFlag:string, retargeting:boolean}}
|
|
668
|
+
*/
|
|
669
|
+
export function buildResumeSpawn(o = {}) {
|
|
670
|
+
const marker = o.marker || {};
|
|
671
|
+
const target = o.target && typeof o.target === "object" ? o.target : null;
|
|
672
|
+
const pathFn = typeof o.augmentedPath === "function" ? o.augmentedPath : augmentedPath;
|
|
673
|
+
const modelFlag = resumeModelFlag(marker, target);
|
|
674
|
+
const knobArgs = [];
|
|
675
|
+
if (target) {
|
|
676
|
+
if (target.maxTurns != null && Number.isFinite(Number(target.maxTurns))) knobArgs.push("--max-turns", String(target.maxTurns));
|
|
677
|
+
if (target.effort) knobArgs.push("--effort", String(target.effort));
|
|
678
|
+
if (target.agentsJson) knobArgs.push("--agents", typeof target.agentsJson === "string" ? target.agentsJson : JSON.stringify(target.agentsJson));
|
|
679
|
+
}
|
|
680
|
+
const args = [
|
|
681
|
+
"--print",
|
|
682
|
+
"--output-format", "json",
|
|
683
|
+
...(Array.isArray(o.permissionArgs) ? o.permissionArgs : []),
|
|
684
|
+
...(Array.isArray(o.daemonArgs) ? o.daemonArgs : []),
|
|
685
|
+
...knobArgs,
|
|
686
|
+
"--session-id", marker.claudeSessionId,
|
|
687
|
+
"--model", modelFlag,
|
|
688
|
+
marker.prompt,
|
|
689
|
+
];
|
|
690
|
+
const base = o.baseEnv && typeof o.baseEnv === "object" ? o.baseEnv : {};
|
|
691
|
+
const envForSpawn = (target && target.envForSpawn) || {};
|
|
692
|
+
const retargeting = !!envForSpawn.ANTHROPIC_BASE_URL;
|
|
693
|
+
const env = retargeting
|
|
694
|
+
? { ...buildChildEnv({ ...base, PATH: pathFn() }, envForSpawn), PATH: pathFn() }
|
|
695
|
+
: { ...base, PATH: pathFn(), ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "", ...envForSpawn };
|
|
696
|
+
if (marker.sessionId) env.MAESTRO_SESSION_ID = String(marker.sessionId);
|
|
697
|
+
return { args, env, modelFlag, retargeting };
|
|
698
|
+
}
|
|
699
|
+
|
|
613
700
|
/**
|
|
614
701
|
* Real resume spawner. Mirrors responder.mjs's blessed session-continuation
|
|
615
702
|
* pattern (see runClaudeCLI ~L116-122 + generateResponse ~L461-465): a
|
|
@@ -625,34 +712,55 @@ export function reconcileResumePending(opts = {}) {
|
|
|
625
712
|
* report cited in responder.mjs). The CLI rehydrates the session-id's history
|
|
626
713
|
* and the fresh prompt continues the work.
|
|
627
714
|
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
630
|
-
*
|
|
631
|
-
*
|
|
715
|
+
* Audit F11: the argv and env are built through the SAME resolveSpawnTarget /
|
|
716
|
+
* buildChildEnv path as spawnSession (buildResumeSpawn), so a retargeted
|
|
717
|
+
* session resumes on its backend, the router's knobs ride along, and
|
|
718
|
+
* `--output-format json` makes the run's usage measurable for the ledger.
|
|
719
|
+
*
|
|
720
|
+
* The resumed session inherits the same permission posture as a fresh spawn.
|
|
721
|
+
* Best-effort; failures are logged, the marker stays (next reconcile retries
|
|
722
|
+
* up to the strike cap). On a clean close the marker is cleared here.
|
|
632
723
|
*/
|
|
633
724
|
function defaultSpawnResume({ marker }) {
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
725
|
+
const source = marker.source || "backlog";
|
|
726
|
+
// The router's CURRENT decision for this work. resolveSpawnTarget never
|
|
727
|
+
// throws (it degrades to the v1 path / stock CLI internally); the request is
|
|
728
|
+
// rebuilt from what the marker kept of the original classification.
|
|
729
|
+
let target = null;
|
|
730
|
+
try {
|
|
731
|
+
const routingConfig = getRoutingConfig();
|
|
732
|
+
if (routingConfig) {
|
|
733
|
+
const classResult = { model: marker.model || "sonnet", priority: marker.priority || "normal", summary: marker.summary || null };
|
|
734
|
+
const routingRequest = requestFromClassifierResult(classResult, { source, role: "responder" });
|
|
735
|
+
target = resolveSpawnTarget(routingConfig, routingRequest, classResult, source, marker.modelFlag || marker.model || "sonnet");
|
|
736
|
+
}
|
|
737
|
+
} catch (err) {
|
|
738
|
+
// A router fault must not block a resume: fall back to the marker's flag.
|
|
739
|
+
console.warn(`[dispatcher] resume routing failed for ${marker.sessionId}, using marker flag: ${err.message}`);
|
|
740
|
+
target = null;
|
|
741
|
+
}
|
|
742
|
+
const { args, env: spawnEnv, modelFlag } = buildResumeSpawn({
|
|
743
|
+
marker,
|
|
744
|
+
target,
|
|
745
|
+
baseEnv: process.env,
|
|
746
|
+
permissionArgs: sessionPermissionArgs({ source: "dispatcher-resume", priority: marker.priority }),
|
|
747
|
+
daemonArgs: daemonClaudeArgs(),
|
|
748
|
+
});
|
|
749
|
+
const decisionId = (target && target.decisionId) || marker.decision_id || null;
|
|
750
|
+
const startedAt = Date.now();
|
|
648
751
|
const proc = _spawn(CLAUDE_BIN, args, { cwd: AGENT_REPO_DIR, env: spawnEnv, stdio: ["ignore", "pipe", "pipe"] });
|
|
752
|
+
let stdout = "";
|
|
649
753
|
let stderr = "";
|
|
754
|
+
proc.stdout.on("data", (c) => { stdout += c.toString(); });
|
|
650
755
|
proc.stderr.on("data", (c) => { stderr += c.toString(); });
|
|
651
756
|
proc.on("close", (code) => {
|
|
757
|
+
// A resumed run costs money too: parse its --output-format json usage.
|
|
758
|
+
try { recordDispatcherCost({ stdout, model: marker.model, durationMs: Date.now() - startedAt, exitCode: code, decisionId }); }
|
|
759
|
+
catch { /* cost tracking is best-effort */ }
|
|
652
760
|
if (code === 0) {
|
|
653
761
|
// Resume succeeded — the underlying work is done; clear the marker.
|
|
654
762
|
clearResumePending(marker.sessionId);
|
|
655
|
-
logSession({ event: "resume_completed", sessionId: marker.sessionId, item_id: marker.itemId });
|
|
763
|
+
logSession({ event: "resume_completed", sessionId: marker.sessionId, item_id: marker.itemId, model_flag: modelFlag });
|
|
656
764
|
} else {
|
|
657
765
|
// A usage limit or 429 during resume must open the breaker so subsequent
|
|
658
766
|
// spawns gate. Usage-limit → hold until reset; 429 → transient backoff.
|
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Health — periodic health metrics and status logging
|
|
3
3
|
|
|
4
|
-
import { writeFileSync, mkdirSync } from "fs";
|
|
4
|
+
import { writeFileSync, mkdirSync, readFileSync } from "fs";
|
|
5
5
|
import { join } from "path";
|
|
6
6
|
import { getStatus } from "./dispatcher.mjs";
|
|
7
|
+
import { resolveRunningSdkVersion } from "./sdk-version.mjs";
|
|
7
8
|
|
|
8
9
|
const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
|
|
9
10
|
|
|
11
|
+
// The SDK version THIS daemon process started on — read once at load, never
|
|
12
|
+
// again: autoupdate installs the next version into node_modules while this
|
|
13
|
+
// process is still running the old (copied) code, and `maestro upgrade
|
|
14
|
+
// --verify` reads `sdk_version` off the dashboard to tell the two apart. The
|
|
15
|
+
// order of trust (sdk-version.mjs): the manifest of the copied tree, then
|
|
16
|
+
// node_modules, then the package this file ships in.
|
|
17
|
+
const DAEMON_SDK_VERSION = resolveRunningSdkVersion({ agentDir: AGENT_REPO_DIR, ownPackagePath: join(new URL(".", import.meta.url).pathname, "../../package.json") }).version;
|
|
18
|
+
|
|
10
19
|
let stats = {
|
|
11
20
|
daemon_start: new Date().toISOString(),
|
|
12
21
|
polls_total: 0,
|
|
@@ -47,6 +56,8 @@ export function writeHealthDashboard() {
|
|
|
47
56
|
const dashboard = {
|
|
48
57
|
updated: new Date().toISOString(),
|
|
49
58
|
uptime_s: Math.floor((Date.now() - new Date(stats.daemon_start).getTime()) / 1000),
|
|
59
|
+
pid: process.pid,
|
|
60
|
+
sdk_version: DAEMON_SDK_VERSION,
|
|
50
61
|
...stats,
|
|
51
62
|
dispatcher,
|
|
52
63
|
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inbox-deferral-session.test.mjs — promoteDeferred must leave a SESSION
|
|
3
|
+
* deferral alone (design §3.3). Both actors park items at `.yaml.deferred`:
|
|
4
|
+
* the daemon for a thread-lock burst (promoted on session close), the main
|
|
5
|
+
* session with `maestro inbox defer --until` (carries a column-0
|
|
6
|
+
* `deferred_until:` marker; the daemon's assurance sweep reopens it when due).
|
|
7
|
+
* A session close for the same channel must neither promote a session
|
|
8
|
+
* deferral early nor fold it away as `.processed-bundled`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { test } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, rmSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
|
|
17
|
+
import { promoteDeferred } from "./inbox-deferral.mjs";
|
|
18
|
+
|
|
19
|
+
function makeAgentRoot() {
|
|
20
|
+
const root = mkdtempSync(join(tmpdir(), "maestro-deferral-session-"));
|
|
21
|
+
mkdirSync(join(root, "state", "inbox", "cohort"), { recursive: true });
|
|
22
|
+
return root;
|
|
23
|
+
}
|
|
24
|
+
function item(id, ts, extra = "") {
|
|
25
|
+
return `id: "${id}"\nservice: "cohort"\nchannel_id: "D001"\nthread_id: "t1"\ntimestamp: "${ts}"\ncontent: |\n body\n${extra}`;
|
|
26
|
+
}
|
|
27
|
+
function ls(root) { return readdirSync(join(root, "state", "inbox", "cohort")).sort(); }
|
|
28
|
+
|
|
29
|
+
test("promoteDeferred: a session deferral (deferred_until marker) is neither promoted nor bundled; legacy deferrals in the same thread still promote", () => {
|
|
30
|
+
const root = makeAgentRoot();
|
|
31
|
+
try {
|
|
32
|
+
// A: session deferral, oldest in the thread.
|
|
33
|
+
writeFileSync(join(root, "state/inbox/cohort/a.yaml.deferred"), item("A", "2026-09-08T09:00:00Z", 'deferred_until: "2026-09-09T09:00:00.000Z"\ndeferred_reason: "waiting on finance"\n'));
|
|
34
|
+
// C: daemon thread-lock deferral, newest — the one a session close promotes.
|
|
35
|
+
writeFileSync(join(root, "state/inbox/cohort/c.yaml.deferred"), item("C", "2026-09-08T10:00:00Z"));
|
|
36
|
+
const r = promoteDeferred("D001", root);
|
|
37
|
+
assert.equal(r.promoted, 1);
|
|
38
|
+
assert.equal(r.bundled, 0, "a session deferral is not a burst duplicate — it must not be folded away");
|
|
39
|
+
assert.deepEqual(ls(root), ["a.yaml.deferred", "c.yaml"]);
|
|
40
|
+
// With no daemon deferral at all, a session close promotes nothing (A is not due yet).
|
|
41
|
+
rmSync(join(root, "state/inbox/cohort/c.yaml"));
|
|
42
|
+
assert.deepEqual(promoteDeferred("D001", root), { promoted: 0, bundled: 0, service: null });
|
|
43
|
+
assert.deepEqual(ls(root), ["a.yaml.deferred"]);
|
|
44
|
+
// An indented look-alike inside the body is NOT a marker (column-0 only).
|
|
45
|
+
writeFileSync(join(root, "state/inbox/cohort/b.yaml.deferred"), `id: "B"\nservice: "cohort"\nchannel_id: "D001"\nthread_id: "t1"\ntimestamp: "2026-09-08T11:00:00Z"\ncontent: |\n deferred_until: "2099-01-01T00:00:00Z"\n`);
|
|
46
|
+
assert.equal(promoteDeferred("D001", root).promoted, 1);
|
|
47
|
+
assert.deepEqual(ls(root), ["a.yaml.deferred", "b.yaml"]);
|
|
48
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
49
|
+
});
|
|
@@ -196,6 +196,12 @@ export function promoteDeferred(channel, agentRoot) {
|
|
|
196
196
|
}
|
|
197
197
|
const fileChannel = readScalar(body, "channel_id");
|
|
198
198
|
if (fileChannel !== channel) continue;
|
|
199
|
+
// A SESSION deferral (`maestro inbox defer --until`, lib/session/
|
|
200
|
+
// inbox-claims) shares the `.deferred` suffix but is not a thread-lock
|
|
201
|
+
// burst: it is reopened by the daemon's assurance sweep when its time
|
|
202
|
+
// comes. Promoting it here would surface it early; bundling it would
|
|
203
|
+
// fold it away as `.processed-bundled` for good. Column-0 key only.
|
|
204
|
+
if (readScalar(body, "deferred_until")) continue;
|
|
199
205
|
matches.push({
|
|
200
206
|
file,
|
|
201
207
|
timestamp: readScalar(body, "timestamp") || "",
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* self-echo.mjs — "is this inbound event actually MY OWN outbound send?"
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT THIS EXISTS TO FIX (observed 2026-08-25 / 08-27 / 08-30, fleet-wide)
|
|
5
|
+
*
|
|
6
|
+
* The daemon's poll lane re-ingested the agent's own outbound messages as fresh
|
|
7
|
+
* inbound events, classified them, and fired a holding acknowledgement at them —
|
|
8
|
+
* so agents replied to their own messages, and in shared DMs two agents acked
|
|
9
|
+
* each other's echoes ("Looking now." / "Looking now.") in a self-sustaining
|
|
10
|
+
* loop that burned a sub-session per cycle and posted into real rooms.
|
|
11
|
+
*
|
|
12
|
+
* There WAS a defensive check in the poll filter, and it is exactly why the
|
|
13
|
+
* defect survived three recurrences: it compared the item's `sender` against
|
|
14
|
+
* `fullName.toLowerCase().replace(/\s+/g, "-")` — "isla-roselli" — while the
|
|
15
|
+
* real events carry the display name verbatim: `sender: "Isla Roselli"`. The
|
|
16
|
+
* normalised forms never met. This module owns the match so it can be tested
|
|
17
|
+
* against the REAL event shapes instead of an assumed one.
|
|
18
|
+
*
|
|
19
|
+
* WHAT A MATCH MEANS AND WHAT WE DO WITH IT
|
|
20
|
+
*
|
|
21
|
+
* A match is dropped at ingestion, LOUDLY: every drop is appended to
|
|
22
|
+
* `logs/daemon/self-echo-drops.jsonl` so an echo that was dropped is auditable
|
|
23
|
+
* and an echo that got through is disprovable. The drop also retires the inbox
|
|
24
|
+
* file (caller's job, via markProcessed) so the same echo is not re-scanned and
|
|
25
|
+
* re-dropped every tick forever.
|
|
26
|
+
*
|
|
27
|
+
* MATCH DISCIPLINE
|
|
28
|
+
*
|
|
29
|
+
* Identity fields actually observed on inbound items (see
|
|
30
|
+
* state/inbox/<service>/*.yaml): `sender` is a display name ("Isla Roselli").
|
|
31
|
+
* Some lanes carry a member id (`sender_id`, `author_id`, `from.id`). We match:
|
|
32
|
+
*
|
|
33
|
+
* - any carried id against the agent's member cuid / org slug / slack id;
|
|
34
|
+
* - `sender_email` against the agent's own email;
|
|
35
|
+
* - the normalised display name against the agent's fullName and
|
|
36
|
+
* preferredName — NOT the bare firstName, which collides with any
|
|
37
|
+
* colleague sharing it — where "normalised" folds case and treats
|
|
38
|
+
* space / dash / underscore / dot as the same separator, so
|
|
39
|
+
* "Isla Roselli", "isla-roselli" and "Isla_Roselli" are all the same
|
|
40
|
+
* person.
|
|
41
|
+
*
|
|
42
|
+
* EVIDENCE ORDER MATTERS. An id or email is authoritative where a display name
|
|
43
|
+
* is circumstantial: when an item CARRIES an id-ish field or an email and it
|
|
44
|
+
* does NOT match this agent, that positively identifies the sender as someone
|
|
45
|
+
* else, and the display-name rung is never consulted — a namesake ("Isla
|
|
46
|
+
* Roselli", different member cuid) must not be dropped on the name collision.
|
|
47
|
+
*
|
|
48
|
+
* Fail-open in the SAFE direction: an unconfigured identity (no config/agent.json)
|
|
49
|
+
* matches nothing, so no genuine inbound is ever dropped by a half-set-up repo.
|
|
50
|
+
*
|
|
51
|
+
* @module scripts/daemon/lib/self-echo
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
"use strict";
|
|
55
|
+
|
|
56
|
+
import { readFileSync, mkdirSync, appendFileSync } from "node:fs";
|
|
57
|
+
import { join } from "node:path";
|
|
58
|
+
|
|
59
|
+
function defaultRoot() {
|
|
60
|
+
return process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../../..");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Fold a display name to a comparable form: case, and the separator zoo
|
|
64
|
+
* ("Isla Roselli" / "isla-roselli" / "Isla_Roselli" / "isla.roselli"). */
|
|
65
|
+
export function normalizeName(s) {
|
|
66
|
+
return String(s || "").toLowerCase().replace(/[\s_.\-/]+/g, " ").trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Cached per agentRoot — the daemon calls this on every polled item.
|
|
70
|
+
const _cache = new Map();
|
|
71
|
+
|
|
72
|
+
/** Test seam: forget every cached identity (e.g. after writing config/agent.json). */
|
|
73
|
+
export function _resetSelfIdentity() { _cache.clear(); }
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The agent's own identity, in matchable form.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} [agentRoot]
|
|
79
|
+
* @returns {{names:Set<string>, ids:Set<string>, emails:Set<string>, firstName:string|null, fullName:string|null}}
|
|
80
|
+
*/
|
|
81
|
+
export function loadSelfIdentity(agentRoot) {
|
|
82
|
+
const root = agentRoot || defaultRoot();
|
|
83
|
+
if (_cache.has(root)) return _cache.get(root);
|
|
84
|
+
|
|
85
|
+
let cfg = {};
|
|
86
|
+
try { cfg = JSON.parse(readFileSync(join(root, "config/agent.json"), "utf-8")); }
|
|
87
|
+
catch { cfg = {}; }
|
|
88
|
+
|
|
89
|
+
// Full / preferred forms ONLY. The bare firstName is deliberately absent:
|
|
90
|
+
// "Isla" as a sender display name is any colleague, contact or CC'd namesake
|
|
91
|
+
// who shares the first name, and dropping their genuine inbound on that
|
|
92
|
+
// collision is a silent-loss bug. The separator-folded FULL name is what the
|
|
93
|
+
// real echo events carry ("Isla Roselli" / "isla-roselli"), and that match
|
|
94
|
+
// is preserved.
|
|
95
|
+
const names = new Set(
|
|
96
|
+
[cfg.fullName, cfg.preferredName]
|
|
97
|
+
.map(normalizeName)
|
|
98
|
+
.filter(Boolean),
|
|
99
|
+
);
|
|
100
|
+
const ids = new Set(
|
|
101
|
+
[
|
|
102
|
+
cfg.memberId, // the org member cuid — what hq stamps as actor
|
|
103
|
+
cfg.slackMemberId,
|
|
104
|
+
cfg.org && cfg.org.cohort && cfg.org.cohort.agentId, // the seat slug ("A016")
|
|
105
|
+
process.env.COHORT_AGENT_ID,
|
|
106
|
+
]
|
|
107
|
+
.map((v) => String(v || "").trim().toLowerCase())
|
|
108
|
+
.filter(Boolean),
|
|
109
|
+
);
|
|
110
|
+
const emails = new Set(
|
|
111
|
+
[cfg.email, cfg.workEmail]
|
|
112
|
+
.map((v) => String(v || "").trim().toLowerCase())
|
|
113
|
+
.filter(Boolean),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const identity = {
|
|
117
|
+
names,
|
|
118
|
+
ids,
|
|
119
|
+
emails,
|
|
120
|
+
firstName: cfg.firstName || null,
|
|
121
|
+
fullName: cfg.fullName || null,
|
|
122
|
+
};
|
|
123
|
+
_cache.set(root, identity);
|
|
124
|
+
return identity;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Does this inbound item name the agent itself as its sender?
|
|
129
|
+
*
|
|
130
|
+
* @param {object} item a daemon inbox item ({sender, sender_email, sender_id?, ...})
|
|
131
|
+
* @param {object} [identity] injectable identity (tests); defaults to loadSelfIdentity()
|
|
132
|
+
* @returns {boolean}
|
|
133
|
+
*/
|
|
134
|
+
export function isSelfSender(item, identity) {
|
|
135
|
+
if (!item || typeof item !== "object") return false;
|
|
136
|
+
const me = identity || loadSelfIdentity();
|
|
137
|
+
if (me.names.size === 0 && me.ids.size === 0 && me.emails.size === 0) return false;
|
|
138
|
+
|
|
139
|
+
// 1. Member / seat ids, wherever a lane put one. Strongest evidence.
|
|
140
|
+
const idCandidates = [
|
|
141
|
+
item.sender_id,
|
|
142
|
+
item.sender_member_id,
|
|
143
|
+
item.author_id,
|
|
144
|
+
item.authorId,
|
|
145
|
+
item.from && (typeof item.from === "object" ? item.from.id : item.from),
|
|
146
|
+
]
|
|
147
|
+
.map((c) => String(c || "").trim().toLowerCase())
|
|
148
|
+
.filter(Boolean);
|
|
149
|
+
for (const v of idCandidates) {
|
|
150
|
+
if (me.ids.has(v)) return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 2. Email (gmail/orgmail lanes).
|
|
154
|
+
const email = String(item.sender_email || "").trim().toLowerCase();
|
|
155
|
+
if (email && me.emails.has(email)) return true;
|
|
156
|
+
|
|
157
|
+
// 3. EXONERATION before the name rung. The item carried an id or an email
|
|
158
|
+
// and NONE of them matched: that is positive evidence the sender is someone
|
|
159
|
+
// else, and it outranks a display-name collision. A namesake with their own
|
|
160
|
+
// member cuid ("Isla Roselli", cuid ≠ ours) must never be dropped as self.
|
|
161
|
+
if (idCandidates.length > 0) return false;
|
|
162
|
+
if (email) return false;
|
|
163
|
+
|
|
164
|
+
// 4. Display name — what the cohort lane actually carries ("Isla Roselli").
|
|
165
|
+
// Reached only when the item carried NO id and NO email at all.
|
|
166
|
+
const sender = normalizeName(item.sender);
|
|
167
|
+
if (sender && me.names.has(sender)) return true;
|
|
168
|
+
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Audit-log a dropped self-echo. Fail-open: a logging failure must never take
|
|
174
|
+
* the poll loop down, and never resurrects the echo.
|
|
175
|
+
*
|
|
176
|
+
* @param {object} item
|
|
177
|
+
* @param {object} [o] {stage, service, agentRoot}
|
|
178
|
+
*/
|
|
179
|
+
export function logSelfEchoDrop(item, o = {}) {
|
|
180
|
+
const root = o.agentRoot || defaultRoot();
|
|
181
|
+
const row = {
|
|
182
|
+
ts: new Date().toISOString(),
|
|
183
|
+
stage: o.stage || "poll",
|
|
184
|
+
service: o.service || (item && item.service) || null,
|
|
185
|
+
id: (item && (item.id || null)),
|
|
186
|
+
raw_ref: (item && (item.raw_ref || null)),
|
|
187
|
+
sender: (item && (item.sender || null)),
|
|
188
|
+
channel: (item && (item.channel_id || item.channel || null)),
|
|
189
|
+
event_kind: (item && (item.event_kind || null)),
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
const dir = join(root, "logs", "daemon");
|
|
193
|
+
mkdirSync(dir, { recursive: true });
|
|
194
|
+
appendFileSync(join(dir, "self-echo-drops.jsonl"), `${JSON.stringify(row)}\n`);
|
|
195
|
+
} catch { /* never throw from the audit trail */ }
|
|
196
|
+
try {
|
|
197
|
+
console.log(`[daemon] SELF-ECHO dropped at ${row.stage}: ${row.raw_ref || row.id} (sender "${row.sender}" is this agent)`);
|
|
198
|
+
} catch { /* */ }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export default { loadSelfIdentity, isSelfSender, logSelfEchoDrop, normalizeName, _resetSelfIdentity };
|