@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,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/session/identity.mjs — WHO the main session is, and how it comes back.
|
|
3
|
+
*
|
|
4
|
+
* The main session's id is STABLE across relaunches so the agent keeps its
|
|
5
|
+
* transcript: `state/session/main-session.json` holds
|
|
6
|
+
*
|
|
7
|
+
* { sessionId, createdAt, resumes, lastLaunchAt?, rotations: [iso…], rotatedFrom? }
|
|
8
|
+
*
|
|
9
|
+
* The first launch of an id runs `claude --session-id <id>`; every later
|
|
10
|
+
* launch runs `claude --resume <id>`. If a resume dies non-zero inside
|
|
11
|
+
* {@link FAILURE_WINDOW_MS} the transcript is presumed missing/corrupt and the
|
|
12
|
+
* supervisor ROTATES to a fresh id — at most {@link MAX_ROTATIONS_PER_HOUR}
|
|
13
|
+
* times per hour, after which it backs off {@link BACKOFF_MS} and lets launchd
|
|
14
|
+
* relaunch it. Every decision here is a pure function of the record, the
|
|
15
|
+
* observed exit and an injected clock; the supervisor does the I/O.
|
|
16
|
+
*
|
|
17
|
+
* Names: the mux session is `maestro-<first>` and the Claude session is
|
|
18
|
+
* `<first>-main`, where `<first>` is {@link agentFirstName} — the SAME
|
|
19
|
+
* derivation `scripts/local-triggers/generate-plists.sh` uses for the launchd
|
|
20
|
+
* labels (lower-cased `firstName` from `config/agent.json`, else an inline
|
|
21
|
+
* literal in `config/agent.ts`, else the directory name minus `-ai`), so the
|
|
22
|
+
* plist, the lock, the mux and the session agree. `identity.test.mjs` runs the
|
|
23
|
+
* generator's block verbatim against this function to pin that.
|
|
24
|
+
*
|
|
25
|
+
* @module lib/session/identity
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
"use strict";
|
|
29
|
+
|
|
30
|
+
import { readFileSync as fsReadFileSync } from "node:fs";
|
|
31
|
+
import { join, basename } from "node:path";
|
|
32
|
+
import { randomUUID } from "node:crypto";
|
|
33
|
+
import { writeJsonAtomic as fsWriteJsonAtomic } from "../fs-atomic.mjs";
|
|
34
|
+
import { sessionPaths } from "./config.mjs";
|
|
35
|
+
|
|
36
|
+
/** A resume that dies inside this window is treated as a broken transcript. */
|
|
37
|
+
export const FAILURE_WINDOW_MS = 20_000;
|
|
38
|
+
/** Rotations allowed in any rolling hour before the supervisor backs off. */
|
|
39
|
+
export const MAX_ROTATIONS_PER_HOUR = 3;
|
|
40
|
+
/** How long the supervisor sleeps once the rotation budget is spent. */
|
|
41
|
+
export const BACKOFF_MS = 10 * 60_000;
|
|
42
|
+
const HOUR_MS = 3_600_000;
|
|
43
|
+
|
|
44
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
45
|
+
|
|
46
|
+
function iso(ms) { return new Date(ms).toISOString(); }
|
|
47
|
+
function nowMs(now) { return typeof now === "function" ? Number(now()) : (now == null ? Date.now() : Number(now)); }
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Pure: parse the on-disk record. Returns null for anything that is not a
|
|
51
|
+
* record carrying a valid session id, so a corrupt file simply means "start
|
|
52
|
+
* fresh" rather than a crash.
|
|
53
|
+
* @param {string|null|undefined} text
|
|
54
|
+
*/
|
|
55
|
+
export function parseMainSession(text) {
|
|
56
|
+
if (typeof text !== "string" || !text.trim()) return null;
|
|
57
|
+
let raw;
|
|
58
|
+
try { raw = JSON.parse(text); } catch { return null; }
|
|
59
|
+
if (!raw || typeof raw !== "object" || !UUID_RE.test(String(raw.sessionId || ""))) return null;
|
|
60
|
+
const rec = {
|
|
61
|
+
sessionId: String(raw.sessionId).toLowerCase(),
|
|
62
|
+
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : "",
|
|
63
|
+
resumes: Number.isInteger(raw.resumes) && raw.resumes >= 0 ? raw.resumes : 0,
|
|
64
|
+
rotations: Array.isArray(raw.rotations) ? raw.rotations.filter((r) => typeof r === "string" && Number.isFinite(Date.parse(r))) : [],
|
|
65
|
+
};
|
|
66
|
+
if (typeof raw.lastLaunchAt === "string") rec.lastLaunchAt = raw.lastLaunchAt;
|
|
67
|
+
if (typeof raw.rotatedFrom === "string") rec.rotatedFrom = raw.rotatedFrom;
|
|
68
|
+
return rec;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Pure: mint a brand-new main-session record.
|
|
73
|
+
* @param {{now?:number|Function, uuid?:Function}} [opts]
|
|
74
|
+
*/
|
|
75
|
+
export function newMainSession(opts = {}) {
|
|
76
|
+
const uuid = opts.uuid || randomUUID;
|
|
77
|
+
return { sessionId: uuid(), createdAt: iso(nowMs(opts.now)), resumes: 0, rotations: [] };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read `state/session/main-session.json`. Fail-open: null when absent/corrupt.
|
|
82
|
+
* @param {string} agentRoot
|
|
83
|
+
* @param {{readFileSync?:Function}} [deps]
|
|
84
|
+
*/
|
|
85
|
+
export function loadMainSession(agentRoot, deps = {}) {
|
|
86
|
+
const readFileSync = deps.readFileSync || fsReadFileSync;
|
|
87
|
+
try {
|
|
88
|
+
return parseMainSession(readFileSync(sessionPaths(agentRoot).mainSessionFile, "utf8"));
|
|
89
|
+
} catch {
|
|
90
|
+
// Absent on first boot, or unreadable — either way the caller starts fresh.
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Persist the record atomically. Returns a result frame rather than throwing.
|
|
97
|
+
* @param {string} agentRoot
|
|
98
|
+
* @param {object} record
|
|
99
|
+
* @param {{writeJsonAtomic?:Function}} [deps]
|
|
100
|
+
* @returns {{ok:true}|{ok:false, error:string}}
|
|
101
|
+
*/
|
|
102
|
+
export function saveMainSession(agentRoot, record, deps = {}) {
|
|
103
|
+
const writeJsonAtomic = deps.writeJsonAtomic || fsWriteJsonAtomic;
|
|
104
|
+
try {
|
|
105
|
+
writeJsonAtomic(sessionPaths(agentRoot).mainSessionFile, record);
|
|
106
|
+
return { ok: true };
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return { ok: false, error: err && err.message ? err.message : String(err) };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The agent's first name as generate-plists.sh derives it, so the launchd
|
|
114
|
+
* label, the lock, the mux session and the Claude session name agree:
|
|
115
|
+
*
|
|
116
|
+
* 1. `config/agent.json` `firstName`, lower-cased (NOT slugged — the
|
|
117
|
+
* generator only lower-cases, and one derivation must win);
|
|
118
|
+
* 2. else `config/agent.ts` with an INLINE quoted literal
|
|
119
|
+
* (`firstName: 'Priya'`; a type-only `firstName: string;` is not a value);
|
|
120
|
+
* 3. else, or when the value is "unconfigured", the agent directory's
|
|
121
|
+
* basename minus a `-ai` suffix (`~/ravi-ai` → `ravi`).
|
|
122
|
+
*
|
|
123
|
+
* "agent" only when even the directory yields nothing. Never throws.
|
|
124
|
+
* @param {string} agentRoot
|
|
125
|
+
* @param {{readFileSync?:Function}} [deps]
|
|
126
|
+
*/
|
|
127
|
+
export function agentFirstName(agentRoot, deps = {}) {
|
|
128
|
+
const readFileSync = deps.readFileSync || fsReadFileSync;
|
|
129
|
+
let first = "";
|
|
130
|
+
try {
|
|
131
|
+
const agent = JSON.parse(readFileSync(join(agentRoot, "config", "agent.json"), "utf8"));
|
|
132
|
+
const v = agent && agent.firstName;
|
|
133
|
+
first = v == null || v === false ? "" : String(v).trim().toLowerCase();
|
|
134
|
+
} catch {
|
|
135
|
+
first = ""; // absent or malformed → next source
|
|
136
|
+
}
|
|
137
|
+
if (!first) {
|
|
138
|
+
try {
|
|
139
|
+
const ts = readFileSync(join(agentRoot, "config", "agent.ts"), "utf8");
|
|
140
|
+
const line = ts.split("\n").find((l) => /firstName:\s*['"]/.test(l));
|
|
141
|
+
const m = line && line.match(/firstName:\s*['"]([^'"]*)['"]/);
|
|
142
|
+
first = m ? m[1].trim().toLowerCase() : "";
|
|
143
|
+
} catch {
|
|
144
|
+
first = "";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!first || first === "unconfigured") first = basename(agentRoot).replace(/-ai$/, ""); // as-is: the generator does not lower-case this one
|
|
148
|
+
return first || "agent";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** `maestro-<first>` — the screen/tmux session name. */
|
|
152
|
+
export function muxSessionName(first) { return `maestro-${first}`; }
|
|
153
|
+
/** `<first>-main` — the Claude session `--name`. */
|
|
154
|
+
export function mainSessionName(first) { return `${first}-main`; }
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Pure: which flag this launch needs. A never-launched id (or `resume:false`)
|
|
158
|
+
* starts with `--session-id`; an id that has run before is `--resume`d.
|
|
159
|
+
* @param {{resumes:number}} record
|
|
160
|
+
* @param {{resume?:boolean}} [cfg]
|
|
161
|
+
* @returns {"session-id"|"resume"}
|
|
162
|
+
*/
|
|
163
|
+
export function launchMode(record, cfg = {}) {
|
|
164
|
+
if (cfg.resume === false) return "session-id";
|
|
165
|
+
return record && record.resumes > 0 ? "resume" : "session-id";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Pure: the record after a launch has been issued.
|
|
170
|
+
* @param {object} record @param {{now?:number|Function}} [opts]
|
|
171
|
+
*/
|
|
172
|
+
export function recordLaunch(record, opts = {}) {
|
|
173
|
+
return { ...record, resumes: (record.resumes || 0) + 1, lastLaunchAt: iso(nowMs(opts.now)) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Pure: what the supervisor does after the mux session ends.
|
|
178
|
+
*
|
|
179
|
+
* relaunch — the session ended (cleanly, or after living past the window):
|
|
180
|
+
* exit 75 and let launchd bring it back with the same id.
|
|
181
|
+
* rotate — a resume died inside the failure window: mint a fresh id first.
|
|
182
|
+
* backoff — the rotation budget for this hour is spent: sleep, then exit 75.
|
|
183
|
+
*
|
|
184
|
+
* An UNKNOWN exit code (the exit file was not written) inside the window is
|
|
185
|
+
* treated as a failure: the cost of a wrong rotation is a fresh transcript,
|
|
186
|
+
* the cost of a wrong relaunch is a seat stuck in a 30-second crash loop.
|
|
187
|
+
*
|
|
188
|
+
* @param {{record:object, mode:"session-id"|"resume", exitCode:number|null, startedAt:number, endedAt:number, now?:number|Function, failureWindowMs?:number, maxRotationsPerHour?:number}} a
|
|
189
|
+
* @returns {{action:"relaunch"}|{action:"rotate"}|{action:"backoff", sleepMs:number}}
|
|
190
|
+
*/
|
|
191
|
+
export function rotationDecision(a) {
|
|
192
|
+
const windowMs = a.failureWindowMs ?? FAILURE_WINDOW_MS;
|
|
193
|
+
const max = a.maxRotationsPerHour ?? MAX_ROTATIONS_PER_HOUR;
|
|
194
|
+
const durationMs = Number(a.endedAt) - Number(a.startedAt);
|
|
195
|
+
const failed = a.exitCode === null || a.exitCode === undefined || a.exitCode !== 0;
|
|
196
|
+
if (a.mode !== "resume" || !failed || !(durationMs < windowMs)) return { action: "relaunch" };
|
|
197
|
+
const t = nowMs(a.now);
|
|
198
|
+
const recent = (a.record && a.record.rotations || []).filter((r) => t - Date.parse(r) < HOUR_MS);
|
|
199
|
+
if (recent.length >= max) return { action: "backoff", sleepMs: BACKOFF_MS };
|
|
200
|
+
return { action: "rotate" };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Pure: the record after a rotation — fresh id, zero resumes, this rotation
|
|
205
|
+
* stamped, and the rotation history pruned to the last hour.
|
|
206
|
+
* @param {object} record @param {{now?:number|Function, uuid?:Function}} [opts]
|
|
207
|
+
*/
|
|
208
|
+
export function rotateMainSession(record, opts = {}) {
|
|
209
|
+
const t = nowMs(opts.now);
|
|
210
|
+
const fresh = newMainSession({ now: t, uuid: opts.uuid });
|
|
211
|
+
const kept = (record.rotations || []).filter((r) => t - Date.parse(r) < HOUR_MS);
|
|
212
|
+
return { ...fresh, rotations: [...kept, iso(t)], rotatedFrom: record.sessionId };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export default {
|
|
216
|
+
FAILURE_WINDOW_MS, MAX_ROTATIONS_PER_HOUR, BACKOFF_MS,
|
|
217
|
+
parseMainSession, newMainSession, loadMainSession, saveMainSession,
|
|
218
|
+
agentFirstName, muxSessionName, mainSessionName,
|
|
219
|
+
launchMode, recordLaunch, rotationDecision, rotateMainSession,
|
|
220
|
+
};
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { join, resolve, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
parseMainSession,
|
|
13
|
+
newMainSession,
|
|
14
|
+
loadMainSession,
|
|
15
|
+
saveMainSession,
|
|
16
|
+
agentFirstName,
|
|
17
|
+
muxSessionName,
|
|
18
|
+
mainSessionName,
|
|
19
|
+
launchMode,
|
|
20
|
+
recordLaunch,
|
|
21
|
+
rotationDecision,
|
|
22
|
+
rotateMainSession,
|
|
23
|
+
FAILURE_WINDOW_MS,
|
|
24
|
+
MAX_ROTATIONS_PER_HOUR,
|
|
25
|
+
BACKOFF_MS,
|
|
26
|
+
} from "./identity.mjs";
|
|
27
|
+
|
|
28
|
+
const T0 = Date.parse("2026-09-08T10:00:00Z");
|
|
29
|
+
const UUID = "11111111-2222-4333-8444-555555555555";
|
|
30
|
+
|
|
31
|
+
test("parseMainSession: valid record round-trips; junk → null", () => {
|
|
32
|
+
const rec = parseMainSession(JSON.stringify({ sessionId: UUID, createdAt: "2026-09-08T09:00:00Z", resumes: 2 }));
|
|
33
|
+
assert.deepEqual(rec, { sessionId: UUID, createdAt: "2026-09-08T09:00:00Z", resumes: 2, rotations: [] });
|
|
34
|
+
assert.equal(parseMainSession("{not json"), null);
|
|
35
|
+
assert.equal(parseMainSession(JSON.stringify({ sessionId: "not-a-uuid" })), null);
|
|
36
|
+
assert.equal(parseMainSession(JSON.stringify({ resumes: 1 })), null);
|
|
37
|
+
assert.equal(parseMainSession(null), null);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("newMainSession: uses the injected uuid + clock, starts at zero resumes", () => {
|
|
41
|
+
const rec = newMainSession({ now: T0, uuid: () => UUID });
|
|
42
|
+
assert.deepEqual(rec, { sessionId: UUID, createdAt: "2026-09-08T10:00:00.000Z", resumes: 0, rotations: [] });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("loadMainSession / saveMainSession go through injected fs and the atomic writer", () => {
|
|
46
|
+
const disk = {};
|
|
47
|
+
const deps = {
|
|
48
|
+
readFileSync: (p) => { if (!(p in disk)) { const e = new Error("ENOENT"); e.code = "ENOENT"; throw e; } return disk[p]; },
|
|
49
|
+
writeJsonAtomic: (p, obj) => { disk[p] = JSON.stringify(obj); },
|
|
50
|
+
};
|
|
51
|
+
assert.equal(loadMainSession("/agent", deps), null);
|
|
52
|
+
const rec = newMainSession({ now: T0, uuid: () => UUID });
|
|
53
|
+
const saved = saveMainSession("/agent", rec, deps);
|
|
54
|
+
assert.deepEqual(saved, { ok: true });
|
|
55
|
+
assert.ok(disk[join("/agent", "state", "session", "main-session.json")]);
|
|
56
|
+
assert.deepEqual(loadMainSession("/agent", deps), rec);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("saveMainSession: a failing writer is a returned error, not a throw", () => {
|
|
60
|
+
const res = saveMainSession("/agent", newMainSession({ now: T0, uuid: () => UUID }), {
|
|
61
|
+
writeJsonAtomic: () => { throw new Error("EROFS"); },
|
|
62
|
+
});
|
|
63
|
+
assert.equal(res.ok, false);
|
|
64
|
+
assert.match(res.error, /EROFS/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("agentFirstName: lower-cased firstName from config/agent.json; agent.ts, then the dir name minus -ai, as generate-plists.sh does", () => {
|
|
68
|
+
const fs = (files) => ({ readFileSync: (p) => { if (!(p in files)) { const e = new Error(`ENOENT ${p}`); e.code = "ENOENT"; throw e; } return files[p]; } });
|
|
69
|
+
const J = join("/Users/o/ravi-ai", "config", "agent.json");
|
|
70
|
+
const T = join("/Users/o/ravi-ai", "config", "agent.ts");
|
|
71
|
+
assert.equal(agentFirstName("/Users/o/ravi-ai", fs({ [J]: '{"firstName":"Olivia"}' })), "olivia");
|
|
72
|
+
// NOT slugged: the plist generator lower-cases only, and the label, the
|
|
73
|
+
// lock, the mux and the session name must agree on one derivation.
|
|
74
|
+
assert.equal(agentFirstName("/Users/o/ravi-ai", fs({ [J]: '{"firstName":"Jean Luc"}' })), "jean luc");
|
|
75
|
+
assert.equal(agentFirstName("/Users/o/ravi-ai", fs({ [J]: '{"firstName":"José"}' })), "josé");
|
|
76
|
+
// Fallbacks in the generator's order: agent.ts inline literal, then the directory.
|
|
77
|
+
assert.equal(agentFirstName("/Users/o/ravi-ai", fs({ [T]: "export const AGENT = { firstName: 'Priya', lastName: 'T' };\n" })), "priya");
|
|
78
|
+
assert.equal(agentFirstName("/Users/o/ravi-ai", fs({ [T]: "interface A { firstName: string; }\n" })), "ravi", "a type-only firstName is not a value");
|
|
79
|
+
assert.equal(agentFirstName("/Users/o/ravi-ai", fs({ [J]: '{"firstName":"UNCONFIGURED"}' })), "ravi");
|
|
80
|
+
assert.equal(agentFirstName("/Users/o/ravi-ai", fs({ [J]: "{" })), "ravi");
|
|
81
|
+
assert.equal(agentFirstName("/Users/o/ravi-ai", fs({})), "ravi");
|
|
82
|
+
assert.equal(agentFirstName("/Users/o/sam", fs({})), "sam");
|
|
83
|
+
// Blank everywhere (no usable dir name either) → "agent".
|
|
84
|
+
assert.equal(agentFirstName("/", fs({ [join("/", "config", "agent.json")]: '{"firstName":" "}' })), "agent");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("agentFirstName agrees with generate-plists.sh's derivation, run verbatim from the script (cross-pin)", () => {
|
|
88
|
+
const script = readFileSync(resolve(__dirname, "..", "..", "scripts", "local-triggers", "generate-plists.sh"), "utf8");
|
|
89
|
+
const start = script.indexOf('AGENT_JSON="$AGENT_DIR/config/agent.json"');
|
|
90
|
+
const end = script.indexOf("AGENT_UPPER=");
|
|
91
|
+
assert.ok(start > 0 && end > start, "generate-plists.sh derivation block markers moved — update this cross-pin AND agentFirstName together");
|
|
92
|
+
const block = script.slice(start, end);
|
|
93
|
+
const shellFirst = (dir) => {
|
|
94
|
+
const r = spawnSync("/bin/bash", ["-c", `AGENT_DIR=${JSON.stringify(dir)}\n${block}\nprintf '%s' "$AGENT_FIRST"`], { encoding: "utf8", env: { PATH: "/usr/bin:/bin", LANG: "en_US.UTF-8", LC_ALL: "en_US.UTF-8" } });
|
|
95
|
+
assert.equal(r.status, 0, r.stderr);
|
|
96
|
+
return r.stdout;
|
|
97
|
+
};
|
|
98
|
+
const cases = [
|
|
99
|
+
["ravi-ai", { json: { firstName: "Olivia" } }],
|
|
100
|
+
["ravi-ai", { json: { firstName: "Jean Luc" } }],
|
|
101
|
+
["ravi-ai", { json: { firstName: "José" } }],
|
|
102
|
+
["ravi-ai", { json: { firstName: "UNCONFIGURED" } }],
|
|
103
|
+
["ravi-ai", { ts: "export const AGENT = { firstName: 'Priya', lastName: 'T' };\n" }],
|
|
104
|
+
["ravi-ai", { ts: "interface A { firstName: string; }\n" }],
|
|
105
|
+
["sam-ai", {}],
|
|
106
|
+
["kai", { json: {} }],
|
|
107
|
+
["Ravi-ai", {}], // the generator does NOT lower-case the directory fallback
|
|
108
|
+
];
|
|
109
|
+
for (const [dirName, fx] of cases) {
|
|
110
|
+
const base = mkdtempSync(join(tmpdir(), "maestro-first-"));
|
|
111
|
+
const dir = join(base, dirName);
|
|
112
|
+
mkdirSync(join(dir, "config"), { recursive: true });
|
|
113
|
+
if (fx.json) writeFileSync(join(dir, "config", "agent.json"), JSON.stringify(fx.json));
|
|
114
|
+
if (fx.ts) writeFileSync(join(dir, "config", "agent.ts"), fx.ts);
|
|
115
|
+
try {
|
|
116
|
+
assert.equal(agentFirstName(dir), shellFirst(dir), `${dirName} ${JSON.stringify(fx)}`);
|
|
117
|
+
} finally {
|
|
118
|
+
rmSync(base, { recursive: true, force: true });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("muxSessionName / mainSessionName", () => {
|
|
124
|
+
assert.equal(muxSessionName("olivia"), "maestro-olivia");
|
|
125
|
+
assert.equal(mainSessionName("olivia"), "olivia-main");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("launchMode: a never-launched id starts fresh; a launched id resumes; resume:false always starts fresh", () => {
|
|
129
|
+
const fresh = newMainSession({ now: T0, uuid: () => UUID });
|
|
130
|
+
assert.equal(launchMode(fresh, { resume: true }), "session-id");
|
|
131
|
+
const launched = recordLaunch(fresh, { now: T0 });
|
|
132
|
+
assert.equal(launched.resumes, 1);
|
|
133
|
+
assert.equal(launched.lastLaunchAt, "2026-09-08T10:00:00.000Z");
|
|
134
|
+
assert.equal(launchMode(launched, { resume: true }), "resume");
|
|
135
|
+
assert.equal(launchMode(launched, { resume: false }), "session-id");
|
|
136
|
+
// recordLaunch is pure — the input is untouched.
|
|
137
|
+
assert.equal(fresh.resumes, 0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("rotationDecision: non-zero exit inside the failure window on a resume → rotate", () => {
|
|
141
|
+
const rec = { sessionId: UUID, createdAt: "x", resumes: 2, rotations: [] };
|
|
142
|
+
const d = rotationDecision({ record: rec, mode: "resume", exitCode: 1, startedAt: T0, endedAt: T0 + 5_000, now: T0 + 5_000 });
|
|
143
|
+
assert.equal(d.action, "rotate");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("rotationDecision: a clean end, a long-lived session, or a fresh-id launch → relaunch (exit 75)", () => {
|
|
147
|
+
const rec = { sessionId: UUID, createdAt: "x", resumes: 2, rotations: [] };
|
|
148
|
+
const base = { record: rec, startedAt: T0, now: T0 + 5_000 };
|
|
149
|
+
assert.equal(rotationDecision({ ...base, mode: "resume", exitCode: 0, endedAt: T0 + 5_000 }).action, "relaunch");
|
|
150
|
+
assert.equal(rotationDecision({ ...base, mode: "resume", exitCode: 1, endedAt: T0 + FAILURE_WINDOW_MS + 1, now: T0 + FAILURE_WINDOW_MS + 1 }).action, "relaunch");
|
|
151
|
+
assert.equal(rotationDecision({ ...base, mode: "session-id", exitCode: 1, endedAt: T0 + 5_000 }).action, "relaunch");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("rotationDecision: an unknown exit code inside the window counts as a failure (fail-safe toward rotation)", () => {
|
|
155
|
+
const rec = { sessionId: UUID, createdAt: "x", resumes: 1, rotations: [] };
|
|
156
|
+
const d = rotationDecision({ record: rec, mode: "resume", exitCode: null, startedAt: T0, endedAt: T0 + 1_000, now: T0 + 1_000 });
|
|
157
|
+
assert.equal(d.action, "rotate");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("rotationDecision: more than MAX_ROTATIONS_PER_HOUR in the last hour → backoff", () => {
|
|
161
|
+
const recent = Array.from({ length: MAX_ROTATIONS_PER_HOUR }, (_, i) => new Date(T0 - (i + 1) * 60_000).toISOString());
|
|
162
|
+
const rec = { sessionId: UUID, createdAt: "x", resumes: 1, rotations: recent };
|
|
163
|
+
const d = rotationDecision({ record: rec, mode: "resume", exitCode: 1, startedAt: T0, endedAt: T0 + 1_000, now: T0 + 1_000 });
|
|
164
|
+
assert.equal(d.action, "backoff");
|
|
165
|
+
assert.equal(d.sleepMs, BACKOFF_MS);
|
|
166
|
+
// Rotations older than an hour no longer count.
|
|
167
|
+
const old = recent.map((iso) => new Date(Date.parse(iso) - 3_600_000).toISOString());
|
|
168
|
+
const d2 = rotationDecision({ record: { ...rec, rotations: old }, mode: "resume", exitCode: 1, startedAt: T0, endedAt: T0 + 1_000, now: T0 + 1_000 });
|
|
169
|
+
assert.equal(d2.action, "rotate");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("rotateMainSession: fresh id, zero resumes, rotation stamped and pruned to the last hour", () => {
|
|
173
|
+
const rec = { sessionId: UUID, createdAt: "x", resumes: 4, rotations: [new Date(T0 - 2 * 3_600_000).toISOString()] };
|
|
174
|
+
const next = rotateMainSession(rec, { now: T0, uuid: () => "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee" });
|
|
175
|
+
assert.equal(next.sessionId, "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee");
|
|
176
|
+
assert.equal(next.resumes, 0);
|
|
177
|
+
assert.deepEqual(next.rotations, ["2026-09-08T10:00:00.000Z"]);
|
|
178
|
+
assert.equal(next.rotatedFrom, UUID);
|
|
179
|
+
assert.equal(rec.resumes, 4);
|
|
180
|
+
});
|