@indigoai-us/hq-cli 5.123.0 → 5.123.1
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/CHANGELOG.md +21 -0
- package/dist/commands/mesh.js +7 -2
- package/dist/commands/update.js +6 -0
- package/dist/lib/mesh/live/backfill-held.d.ts +43 -7
- package/dist/lib/mesh/live/backfill-held.js +323 -42
- package/dist/lib/mesh/live/daemon/config.d.ts +9 -0
- package/dist/lib/mesh/live/daemon/config.js +45 -0
- package/dist/lib/mesh/live/daemon/doctor.d.ts +4 -0
- package/dist/lib/mesh/live/daemon/doctor.js +17 -2
- package/dist/lib/mesh/live/daemon/index.d.ts +4 -2
- package/dist/lib/mesh/live/daemon/index.js +3 -2
- package/dist/lib/mesh/live/daemon/install.d.ts +27 -0
- package/dist/lib/mesh/live/daemon/install.js +54 -4
- package/dist/lib/mesh/live/daemon/run.d.ts +6 -0
- package/dist/lib/mesh/live/daemon/run.js +44 -2
- package/dist/lib/mesh/live/daemon/version-drift.d.ts +5 -0
- package/dist/lib/mesh/live/daemon/version-drift.js +47 -0
- package/dist/lib/mesh/live/paths.d.ts +2 -0
- package/dist/lib/mesh/live/paths.js +4 -0
- package/dist/lib/work-context/reconcile.js +67 -6
- package/dist/lib/work-context/state.d.ts +5 -3
- package/dist/lib/work-context/state.js +28 -4
- package/dist/utils/self-update.d.ts +3 -0
- package/dist/utils/self-update.js +11 -0
- package/package.json +1 -1
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
export function daemonConfigPath(daemonDirectory) {
|
|
4
|
+
return path.join(daemonDirectory, "config.json");
|
|
5
|
+
}
|
|
6
|
+
export function verifyHqRoot(candidate) {
|
|
7
|
+
if (!candidate?.trim())
|
|
8
|
+
return null;
|
|
9
|
+
try {
|
|
10
|
+
const root = fs.realpathSync(candidate);
|
|
11
|
+
return fs.statSync(path.join(root, "companies", "manifest.yaml")).isFile()
|
|
12
|
+
? root
|
|
13
|
+
: null;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function readDaemonConfig(daemonDirectory) {
|
|
20
|
+
try {
|
|
21
|
+
const raw = JSON.parse(fs.readFileSync(daemonConfigPath(daemonDirectory), "utf8"));
|
|
22
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
23
|
+
return { trustedHqRoots: [] };
|
|
24
|
+
const roots = raw.trustedHqRoots;
|
|
25
|
+
if (!Array.isArray(roots))
|
|
26
|
+
return { trustedHqRoots: [] };
|
|
27
|
+
return { trustedHqRoots: roots.filter((root) => typeof root === "string")
|
|
28
|
+
.map(verifyHqRoot).filter((root) => Boolean(root)) };
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return { trustedHqRoots: [] };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Record a verified local HQ checkout without trusting a caller-supplied path at runtime. */
|
|
35
|
+
export function registerTrustedHqRoot(daemonDirectory, candidate) {
|
|
36
|
+
const root = verifyHqRoot(candidate);
|
|
37
|
+
const current = readDaemonConfig(daemonDirectory);
|
|
38
|
+
if (!root || current.trustedHqRoots.includes(root))
|
|
39
|
+
return current;
|
|
40
|
+
const next = { trustedHqRoots: [...current.trustedHqRoots, root] };
|
|
41
|
+
fs.mkdirSync(daemonDirectory, { recursive: true, mode: 0o700 });
|
|
42
|
+
fs.writeFileSync(daemonConfigPath(daemonDirectory), `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
43
|
+
return next;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -17,6 +17,8 @@ export interface DaemonDoctorReport {
|
|
|
17
17
|
subscribed: boolean;
|
|
18
18
|
spoolDepth: number;
|
|
19
19
|
heldCount: number;
|
|
20
|
+
/** Distinct held sessions with neither bound state nor local company evidence. */
|
|
21
|
+
heldWithNoLocalEvidence: number;
|
|
20
22
|
deadLetterCount: number;
|
|
21
23
|
/** Events in any local queue still marked no-company / NEEDS_COMPANY / NONE. */
|
|
22
24
|
unattributedEvents: UnattributedEventCounts;
|
|
@@ -50,6 +52,8 @@ export interface DaemonDoctorDeps {
|
|
|
50
52
|
home?: string;
|
|
51
53
|
meshRoot?: string;
|
|
52
54
|
workContextRoot?: string;
|
|
55
|
+
/** Independently configured HQ root for held-event evidence checks. */
|
|
56
|
+
hqRoot?: string;
|
|
53
57
|
env?: NodeJS.ProcessEnv;
|
|
54
58
|
isProcessAlive?: (pid: number) => boolean;
|
|
55
59
|
now?: () => number;
|
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
6
|
import * as os from "node:os";
|
|
7
7
|
import { describeCognitoTokenSource, } from "../../../../utils/cognito-session.js";
|
|
8
|
-
import { outboxStats, } from "../../../work-context/outbox.js";
|
|
8
|
+
import { listOutboxOperations, outboxStats, } from "../../../work-context/outbox.js";
|
|
9
|
+
import { readSessionState } from "../../../work-context/state.js";
|
|
9
10
|
import { workContextRoot } from "../../../work-context/paths.js";
|
|
10
11
|
import { countJsonlLines, countUnattributedEvents, } from "../spool.js";
|
|
11
12
|
import { workMeshDeadLetterPath, workMeshHeldPath, workMeshRoot, workMeshSpoolPath, } from "../paths.js";
|
|
12
13
|
import { daemonDir, daemonPidPath } from "./paths.js";
|
|
13
14
|
import { readEmitState } from "../emit.js";
|
|
14
15
|
import { emitRetryDepth } from "../emit-retry.js";
|
|
16
|
+
import { hasCurrentAcknowledgedRegistration, readHeldSessions, refWithLocalCompanyEvidence, } from "../backfill-held.js";
|
|
17
|
+
import { readDaemonConfig } from "./config.js";
|
|
15
18
|
import { pidLockStatus, readPidLock } from "./pid-lock.js";
|
|
16
19
|
import { readDaemonState } from "./state.js";
|
|
17
20
|
export const UNHEALTHY_SPOOL_AGE_MS = 60_000;
|
|
@@ -77,6 +80,14 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
77
80
|
const deadPath = workMeshDeadLetterPath(meshRoot);
|
|
78
81
|
const spoolDepth = countJsonlLines(spoolPath);
|
|
79
82
|
const heldCount = countJsonlLines(heldPath);
|
|
83
|
+
const trustedRoots = readDaemonConfig(dir).trustedHqRoots;
|
|
84
|
+
const outboxOperations = listOutboxOperations(ctxRoot);
|
|
85
|
+
const heldWithNoLocalEvidence = readHeldSessions(meshRoot).filter((ref) => {
|
|
86
|
+
const state = readSessionState(ref.sessionId, ctxRoot);
|
|
87
|
+
return (!state?.companyUid &&
|
|
88
|
+
!hasCurrentAcknowledgedRegistration(ref, state, outboxOperations, deps.hqRoot ?? env.HQ_ROOT?.trim(), trustedRoots) &&
|
|
89
|
+
!refWithLocalCompanyEvidence(ref, deps.hqRoot ?? env.HQ_ROOT?.trim(), trustedRoots, state));
|
|
90
|
+
}).length;
|
|
80
91
|
const deadLetterCount = countJsonlLines(deadPath);
|
|
81
92
|
const unattributedEvents = countUnattributedEvents(meshRoot);
|
|
82
93
|
const outbox = outboxStats(ctxRoot);
|
|
@@ -100,6 +111,9 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
100
111
|
if (emitMode === "legacy" && heldCount > 0) {
|
|
101
112
|
unhealthyReasons.push(`held events awaiting company attribution=${heldCount}`);
|
|
102
113
|
}
|
|
114
|
+
if (heldWithNoLocalEvidence > 0) {
|
|
115
|
+
unhealthyReasons.push(`held with no local evidence=${heldWithNoLocalEvidence}`);
|
|
116
|
+
}
|
|
103
117
|
if (unattributedEvents.total > 0) {
|
|
104
118
|
unhealthyReasons.push(`unattributed events=${unattributedEvents.total} ` +
|
|
105
119
|
`(spool=${unattributedEvents.spool} held=${unattributedEvents.held} dead-letter=${unattributedEvents.deadLetter})`);
|
|
@@ -125,6 +139,7 @@ export function collectDaemonDoctor(deps = {}) {
|
|
|
125
139
|
companiesOnline,
|
|
126
140
|
spoolDepth,
|
|
127
141
|
heldCount,
|
|
142
|
+
heldWithNoLocalEvidence,
|
|
128
143
|
deadLetterCount,
|
|
129
144
|
unattributedEvents,
|
|
130
145
|
outboxDepth: outbox.depth,
|
|
@@ -162,7 +177,7 @@ export function formatDaemonDoctor(report) {
|
|
|
162
177
|
if (report.emitMode === "legacy") {
|
|
163
178
|
// Legacy emit path only: the spool/held/outbox queues do not exist in
|
|
164
179
|
// receive-only (direct) mode.
|
|
165
|
-
lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `outbox depth: ${report.outboxDepth}`);
|
|
180
|
+
lines.push(`spool depth: ${report.spoolDepth}`, `held: ${report.heldCount}`, `held with no local evidence: ${report.heldWithNoLocalEvidence}`, `outbox depth: ${report.outboxDepth}`);
|
|
166
181
|
}
|
|
167
182
|
lines.push(`dead-letter: ${report.deadLetterCount}`, `unattributed events: ${report.unattributedEvents.total} ` +
|
|
168
183
|
`(spool=${report.unattributedEvents.spool} held=${report.unattributedEvents.held} dead-letter=${report.unattributedEvents.deadLetter})`, `token source: ${report.tokenSource}`, `actor kind: ${report.actorKind}`);
|
|
@@ -12,12 +12,14 @@ export type { DaemonStateFile, PresenceRefusalState } from "./state.js";
|
|
|
12
12
|
export { appendDaemonLog, daemonLogLine, ensureDaemonLog, resolveDaemonAssetDir, rotateDaemonLogIfNeeded, } from "./log.js";
|
|
13
13
|
export { BOARD_REFRESH_INTERVAL_MS, createVaultBoardReader, formatBoardMarkdown, refreshBoundSessionBoards, writeBoardMarkdown, } from "./board-refresh.js";
|
|
14
14
|
export type { BoardReader, BoardRefreshDeps, BoardStorySnapshot } from "./board-refresh.js";
|
|
15
|
-
export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonService, readAssetTemplate, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveHqBinary, resolveNodeBinary, uninstallDaemonService, } from "./install.js";
|
|
15
|
+
export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonService, readAssetTemplate, restartDaemonServiceIfInstalled, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveHqBinary, resolveNodeBinary, uninstallDaemonService, } from "./install.js";
|
|
16
16
|
export type { DaemonInstallPlatform, InstallPaths, InstallResult, } from "./install.js";
|
|
17
|
+
export { daemonConfigPath, readDaemonConfig, registerTrustedHqRoot, verifyHqRoot } from "./config.js";
|
|
18
|
+
export type { DaemonConfig } from "./config.js";
|
|
17
19
|
export { UNHEALTHY_SPOOL_AGE_MS, collectDaemonDoctor, formatDaemonDoctor, } from "./doctor.js";
|
|
18
20
|
export type { DaemonDoctorDeps, DaemonDoctorReport } from "./doctor.js";
|
|
19
21
|
export { scopeBundleToAgentIdentity, } from "./credentials.js";
|
|
20
|
-
export { FLUSH_INTERVAL_MS, SPOOL_DEBOUNCE_MS, runMeshDaemon, } from "./run.js";
|
|
22
|
+
export { FLUSH_INTERVAL_MS, DAEMON_VERSION_DRIFT_EXIT_CODE, SPOOL_DEBOUNCE_MS, runMeshDaemon, } from "./run.js";
|
|
21
23
|
export type { DaemonHandle, DaemonRunDeps } from "./run.js";
|
|
22
24
|
export { TRANSCRIPT_ADAPTER_VERSION, TRANSCRIPT_EVENT_SOURCE, TRANSCRIPT_FRESH_MS, TRANSCRIPT_HOOK_COVER_MS, TRANSCRIPT_MARKER, TRANSCRIPT_SESSION_END_MS, TRANSCRIPT_TURN_QUIET_MS, TRANSCRIPT_WATCH_BACKOFF_DURATION_MS, TRANSCRIPT_WATCH_BACKOFF_ENTRIES, TRANSCRIPT_WATCH_ENTRY_CAP, TRANSCRIPT_WATCH_INTERVAL_MAX_MS, TRANSCRIPT_WATCH_INTERVAL_MS, TRANSCRIPT_WATCH_REVALIDATE_MS, TranscriptWatcher, createTranscriptDiscoverCache, decodeClaudeProjectDirName, defaultTranscriptFs, discoverTranscripts, discoverTranscriptsWithMeta, isTranscriptHookCovered, nextTranscriptWatchIntervalMs, noteHookSessionsFromSpoolFile, resolveTranscriptRegistration, sessionIdFromClaudeTranscriptPath, sessionIdFromCodexTranscriptPath, sortDiscoveries, } from "./transcript-watch.js";
|
|
23
25
|
export type { DiscoveredTranscript, DiscoverScanMeta, DiscoverTranscriptsResult, TranscriptDiscoverCache, TranscriptFs, TranscriptKind, TranscriptWatchDeps, TranscriptWatchTickResult, } from "./transcript-watch.js";
|
|
@@ -6,9 +6,10 @@ export { CLOSE_LOG_ALWAYS_COUNT, CLOSE_LOG_INTERVAL_MS, CONNECT_STABLE_GRACE_MS,
|
|
|
6
6
|
export { defaultDaemonState, patchDaemonState, readDaemonState, writeDaemonState, } from "./state.js";
|
|
7
7
|
export { appendDaemonLog, daemonLogLine, ensureDaemonLog, resolveDaemonAssetDir, rotateDaemonLogIfNeeded, } from "./log.js";
|
|
8
8
|
export { BOARD_REFRESH_INTERVAL_MS, createVaultBoardReader, formatBoardMarkdown, refreshBoundSessionBoards, writeBoardMarkdown, } from "./board-refresh.js";
|
|
9
|
-
export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonService, readAssetTemplate, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveHqBinary, resolveNodeBinary, uninstallDaemonService, } from "./install.js";
|
|
9
|
+
export { buildInstallPaths, daemonServiceStatus, detectPlatform, installDaemonService, readAssetTemplate, restartDaemonServiceIfInstalled, renderLaunchdPlist, renderSystemdSystemUnit, renderSystemdUserUnit, resolveHqBinary, resolveNodeBinary, uninstallDaemonService, } from "./install.js";
|
|
10
|
+
export { daemonConfigPath, readDaemonConfig, registerTrustedHqRoot, verifyHqRoot } from "./config.js";
|
|
10
11
|
export { UNHEALTHY_SPOOL_AGE_MS, collectDaemonDoctor, formatDaemonDoctor, } from "./doctor.js";
|
|
11
12
|
export { scopeBundleToAgentIdentity, } from "./credentials.js";
|
|
12
|
-
export { FLUSH_INTERVAL_MS, SPOOL_DEBOUNCE_MS, runMeshDaemon, } from "./run.js";
|
|
13
|
+
export { FLUSH_INTERVAL_MS, DAEMON_VERSION_DRIFT_EXIT_CODE, SPOOL_DEBOUNCE_MS, runMeshDaemon, } from "./run.js";
|
|
13
14
|
export { TRANSCRIPT_ADAPTER_VERSION, TRANSCRIPT_EVENT_SOURCE, TRANSCRIPT_FRESH_MS, TRANSCRIPT_HOOK_COVER_MS, TRANSCRIPT_MARKER, TRANSCRIPT_SESSION_END_MS, TRANSCRIPT_TURN_QUIET_MS, TRANSCRIPT_WATCH_BACKOFF_DURATION_MS, TRANSCRIPT_WATCH_BACKOFF_ENTRIES, TRANSCRIPT_WATCH_ENTRY_CAP, TRANSCRIPT_WATCH_INTERVAL_MAX_MS, TRANSCRIPT_WATCH_INTERVAL_MS, TRANSCRIPT_WATCH_REVALIDATE_MS, TranscriptWatcher, createTranscriptDiscoverCache, decodeClaudeProjectDirName, defaultTranscriptFs, discoverTranscripts, discoverTranscriptsWithMeta, isTranscriptHookCovered, nextTranscriptWatchIntervalMs, noteHookSessionsFromSpoolFile, resolveTranscriptRegistration, sessionIdFromClaudeTranscriptPath, sessionIdFromCodexTranscriptPath, sortDiscoveries, } from "./transcript-watch.js";
|
|
14
15
|
//# sourceMappingURL=index.js.map
|
|
@@ -23,6 +23,8 @@ export interface InstallPaths {
|
|
|
23
23
|
systemdUnitPath: string;
|
|
24
24
|
/** When true, render the root/ec2-user system unit instead of user unit. */
|
|
25
25
|
systemUser?: string;
|
|
26
|
+
/** Verified HQ root to persist and pass to the installed service. */
|
|
27
|
+
hqRoot?: string;
|
|
26
28
|
}
|
|
27
29
|
export interface InstallResult {
|
|
28
30
|
platform: DaemonInstallPlatform;
|
|
@@ -33,6 +35,26 @@ export interface InstallResult {
|
|
|
33
35
|
running?: boolean;
|
|
34
36
|
message: string;
|
|
35
37
|
}
|
|
38
|
+
/** Best-effort restart result used after an in-place CLI upgrade. */
|
|
39
|
+
export interface RestartDaemonResult {
|
|
40
|
+
attempted: boolean;
|
|
41
|
+
restarted: boolean;
|
|
42
|
+
message?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Restart the installed user daemon after a CLI upgrade. This is deliberately
|
|
46
|
+
* best-effort: an update must remain successful if a user has no service,
|
|
47
|
+
* launchctl/systemd is unavailable, or the user manager is not running.
|
|
48
|
+
*/
|
|
49
|
+
export declare function restartDaemonServiceIfInstalled(opts?: {
|
|
50
|
+
platform?: DaemonInstallPlatform;
|
|
51
|
+
home?: string;
|
|
52
|
+
uid?: number;
|
|
53
|
+
existsSync?: typeof fs.existsSync;
|
|
54
|
+
run?: (command: string, args: string[]) => {
|
|
55
|
+
status: number | null;
|
|
56
|
+
};
|
|
57
|
+
}): RestartDaemonResult;
|
|
36
58
|
export declare function detectPlatform(platform?: NodeJS.Platform): DaemonInstallPlatform;
|
|
37
59
|
/**
|
|
38
60
|
* HQ updates moved the CLI from toolchain/node/bin/hq to
|
|
@@ -53,6 +75,7 @@ export declare function buildInstallPaths(opts: {
|
|
|
53
75
|
env?: NodeJS.ProcessEnv;
|
|
54
76
|
argv?: string[];
|
|
55
77
|
execPath?: string;
|
|
78
|
+
hqRoot?: string;
|
|
56
79
|
}): InstallPaths;
|
|
57
80
|
export declare function renderLaunchdPlist(paths: InstallPaths): string;
|
|
58
81
|
export declare function renderSystemdUserUnit(paths: InstallPaths): string;
|
|
@@ -70,6 +93,10 @@ export declare function installDaemonService(opts: {
|
|
|
70
93
|
mkdirSync?: typeof fs.mkdirSync;
|
|
71
94
|
/** Optional: actually load the service (disabled in tests). */
|
|
72
95
|
activate?: (platform: DaemonInstallPlatform, dest: string) => void;
|
|
96
|
+
/** Reload a rewritten user unit so a legacy Restart=on-failure unit migrates immediately. */
|
|
97
|
+
reloadSystemd?: () => {
|
|
98
|
+
status: number | null;
|
|
99
|
+
};
|
|
73
100
|
}): InstallResult;
|
|
74
101
|
export declare function uninstallDaemonService(opts: {
|
|
75
102
|
platform?: DaemonInstallPlatform;
|
|
@@ -12,8 +12,54 @@
|
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as os from "node:os";
|
|
14
14
|
import * as path from "node:path";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
15
16
|
import { resolveDaemonAssetDir } from "./log.js";
|
|
16
17
|
import { LAUNCHD_LABEL, SYSTEMD_UNIT_NAME, daemonDir, daemonLogPath, } from "./paths.js";
|
|
18
|
+
import { registerTrustedHqRoot } from "./config.js";
|
|
19
|
+
/**
|
|
20
|
+
* Restart the installed user daemon after a CLI upgrade. This is deliberately
|
|
21
|
+
* best-effort: an update must remain successful if a user has no service,
|
|
22
|
+
* launchctl/systemd is unavailable, or the user manager is not running.
|
|
23
|
+
*/
|
|
24
|
+
export function restartDaemonServiceIfInstalled(opts = {}) {
|
|
25
|
+
const platform = opts.platform ?? detectPlatform();
|
|
26
|
+
const home = opts.home ?? os.homedir();
|
|
27
|
+
const existsSync = opts.existsSync ?? fs.existsSync;
|
|
28
|
+
const run = opts.run ?? ((command, args) => spawnSync(command, args, { stdio: "ignore" }));
|
|
29
|
+
try {
|
|
30
|
+
if (platform === "darwin") {
|
|
31
|
+
const plist = path.join(home, "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
32
|
+
if (!existsSync(plist))
|
|
33
|
+
return { attempted: false, restarted: false };
|
|
34
|
+
const uid = opts.uid ?? process.getuid?.();
|
|
35
|
+
if (typeof uid !== "number")
|
|
36
|
+
return { attempted: false, restarted: false };
|
|
37
|
+
const result = run("launchctl", ["kickstart", "-k", `gui/${uid}/${LAUNCHD_LABEL}`]);
|
|
38
|
+
return { attempted: true, restarted: result.status === 0 };
|
|
39
|
+
}
|
|
40
|
+
if (platform === "linux") {
|
|
41
|
+
const unit = path.join(home, ".config", "systemd", "user", SYSTEMD_UNIT_NAME);
|
|
42
|
+
if (existsSync(unit)) {
|
|
43
|
+
const result = run("systemctl", ["--user", "restart", SYSTEMD_UNIT_NAME]);
|
|
44
|
+
return { attempted: true, restarted: result.status === 0 };
|
|
45
|
+
}
|
|
46
|
+
const systemUnit = path.join("/etc", "systemd", "system", SYSTEMD_UNIT_NAME);
|
|
47
|
+
if (existsSync(systemUnit)) {
|
|
48
|
+
return {
|
|
49
|
+
attempted: true,
|
|
50
|
+
restarted: false,
|
|
51
|
+
message: "system unit detected; restart requires the box's service manager",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { attempted: false, restarted: false };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// The caller has already completed the update; service restart failure is
|
|
59
|
+
// intentionally non-fatal and will be retried by normal service recovery.
|
|
60
|
+
}
|
|
61
|
+
return { attempted: false, restarted: false };
|
|
62
|
+
}
|
|
17
63
|
export function detectPlatform(platform = process.platform) {
|
|
18
64
|
if (platform === "darwin")
|
|
19
65
|
return "darwin";
|
|
@@ -83,6 +129,7 @@ export function buildInstallPaths(opts) {
|
|
|
83
129
|
systemdUserDir: path.join(home, ".config", "systemd", "user"),
|
|
84
130
|
systemdUnitPath: path.join(home, ".config", "systemd", "user", SYSTEMD_UNIT_NAME),
|
|
85
131
|
systemUser: opts.systemUser,
|
|
132
|
+
hqRoot: opts.hqRoot,
|
|
86
133
|
};
|
|
87
134
|
}
|
|
88
135
|
export function renderLaunchdPlist(paths) {
|
|
@@ -116,6 +163,7 @@ export function renderLaunchdPlist(paths) {
|
|
|
116
163
|
<string>${escapeXml(paths.home)}</string>
|
|
117
164
|
<key>PATH</key>
|
|
118
165
|
<string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
166
|
+
${paths.hqRoot ? `<key>HQ_ROOT</key>\n <string>${escapeXml(paths.hqRoot)}</string>` : ""}
|
|
119
167
|
</dict>
|
|
120
168
|
</dict>
|
|
121
169
|
</plist>
|
|
@@ -131,11 +179,11 @@ Wants=network-online.target
|
|
|
131
179
|
Type=simple
|
|
132
180
|
ExecStart=${shellQuote(paths.nodeBinary)} ${shellQuote(paths.hqBinary)} mesh daemon run
|
|
133
181
|
WorkingDirectory=${paths.daemonDir}
|
|
134
|
-
Restart=
|
|
182
|
+
Restart=always
|
|
135
183
|
RestartSec=5
|
|
136
184
|
Environment=HOME=${paths.home}
|
|
137
185
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
|
138
|
-
StandardOutput=append:${paths.logPath}
|
|
186
|
+
${paths.hqRoot ? `Environment=HQ_ROOT=${shellQuote(paths.hqRoot)}\n` : ""}StandardOutput=append:${paths.logPath}
|
|
139
187
|
StandardError=append:${paths.logPath}
|
|
140
188
|
|
|
141
189
|
[Install]
|
|
@@ -163,13 +211,13 @@ User=${user}
|
|
|
163
211
|
Group=${user}
|
|
164
212
|
ExecStart=${shellQuote(paths.nodeBinary)} ${shellQuote(paths.hqBinary)} mesh daemon run
|
|
165
213
|
WorkingDirectory=/home/${user}/.hq/work-mesh/daemon
|
|
166
|
-
Restart=
|
|
214
|
+
Restart=always
|
|
167
215
|
RestartSec=5
|
|
168
216
|
Environment=HOME=/home/${user}
|
|
169
217
|
Environment=PATH=/usr/local/bin:/usr/bin:/bin
|
|
170
218
|
Environment=HQ_MACHINE_CREDS_FILE=/home/${user}/.hq-agent/machine-creds.json
|
|
171
219
|
Environment=HQ_AGENT_IDENTITY_FILE=/var/lib/hq-agent/identity.json
|
|
172
|
-
StandardOutput=append:/home/${user}/.hq/work-mesh/daemon/log
|
|
220
|
+
${paths.hqRoot ? `Environment=HQ_ROOT=${shellQuote(paths.hqRoot)}\n` : ""}StandardOutput=append:/home/${user}/.hq/work-mesh/daemon/log
|
|
173
221
|
StandardError=append:/home/${user}/.hq/work-mesh/daemon/log
|
|
174
222
|
|
|
175
223
|
[Install]
|
|
@@ -195,6 +243,7 @@ export function installDaemonService(opts) {
|
|
|
195
243
|
const writeFileSync = opts.writeFileSync ?? fs.writeFileSync;
|
|
196
244
|
const mkdirSync = opts.mkdirSync ?? fs.mkdirSync;
|
|
197
245
|
mkdirSync(paths.daemonDir, { recursive: true, mode: 0o700 });
|
|
246
|
+
registerTrustedHqRoot(paths.daemonDir, paths.hqRoot);
|
|
198
247
|
if (platform === "darwin") {
|
|
199
248
|
const rendered = renderLaunchdPlist(paths);
|
|
200
249
|
mkdirSync(paths.launchAgentsDir, { recursive: true });
|
|
@@ -232,6 +281,7 @@ export function installDaemonService(opts) {
|
|
|
232
281
|
const rendered = renderSystemdUserUnit(paths);
|
|
233
282
|
mkdirSync(paths.systemdUserDir, { recursive: true });
|
|
234
283
|
writeFileSync(paths.systemdUnitPath, rendered, { mode: 0o600 });
|
|
284
|
+
(opts.reloadSystemd ?? (() => spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" })))();
|
|
235
285
|
opts.activate?.(platform, paths.systemdUnitPath);
|
|
236
286
|
return {
|
|
237
287
|
platform,
|
|
@@ -19,6 +19,8 @@ export declare const SPOOL_DEBOUNCE_MS = 2000;
|
|
|
19
19
|
export declare const FLUSH_INTERVAL_MS = 10000;
|
|
20
20
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
21
21
|
export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
22
|
+
/** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
|
|
23
|
+
export declare const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
|
|
22
24
|
/**
|
|
23
25
|
* Only producer appends to spool.jsonl should wake the watcher. Every other
|
|
24
26
|
* file in the work-mesh root (held.jsonl, spool.<ts>.claimed, held.<ts>.claimed,
|
|
@@ -80,6 +82,10 @@ export interface DaemonRunDeps {
|
|
|
80
82
|
log?: (dir: string, line: string) => void;
|
|
81
83
|
/** Stop after this many ms (tests). */
|
|
82
84
|
runForMs?: number;
|
|
85
|
+
/** CLI version loaded by this daemon. Defaults to this package's version. */
|
|
86
|
+
daemonVersion?: string;
|
|
87
|
+
/** Read the currently installed CLI version behind the launch entrypoint. */
|
|
88
|
+
readInstalledCliVersion?: () => string | null;
|
|
83
89
|
random?: () => number;
|
|
84
90
|
now?: () => Date;
|
|
85
91
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as os from "node:os";
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
+
import { CLI_VERSION } from "../../../../cli-version.js";
|
|
14
15
|
import { createWorkSessionDeliverer } from "../../client.js";
|
|
15
16
|
import { meshJson, resolveActiveMembershipCompany } from "../../api.js";
|
|
16
17
|
import { ensureCognitoToken } from "../../../../utils/cognito-session.js";
|
|
@@ -31,10 +32,14 @@ import { defaultDaemonState, patchDaemonState, writeDaemonState, } from "./state
|
|
|
31
32
|
import { workMeshHeldPath } from "../paths.js";
|
|
32
33
|
import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, TranscriptWatcher, } from "./transcript-watch.js";
|
|
33
34
|
import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
|
|
35
|
+
import { hasCliVersionDrift, readInstalledCliVersion } from "./version-drift.js";
|
|
36
|
+
import { readDaemonConfig } from "./config.js";
|
|
34
37
|
export const SPOOL_DEBOUNCE_MS = 2_000;
|
|
35
38
|
export const FLUSH_INTERVAL_MS = 10_000;
|
|
36
39
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
37
40
|
export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
41
|
+
/** Non-zero exit reserved for CLI version drift so legacy Restart=on-failure units restart. */
|
|
42
|
+
export const DAEMON_VERSION_DRIFT_EXIT_CODE = 75;
|
|
38
43
|
/**
|
|
39
44
|
* Only producer appends to spool.jsonl should wake the watcher. Every other
|
|
40
45
|
* file in the work-mesh root (held.jsonl, spool.<ts>.claimed, held.<ts>.claimed,
|
|
@@ -76,7 +81,15 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
76
81
|
((d, line) => appendDaemonLog(d, daemonLogLine("info", line, now)));
|
|
77
82
|
const processEvents = deps.processEvents ?? process;
|
|
78
83
|
const exit = deps.exit ?? ((code) => process.exit(code));
|
|
84
|
+
const daemonVersion = deps.daemonVersion ?? CLI_VERSION;
|
|
85
|
+
const installedCliVersion = deps.readInstalledCliVersion ?? readInstalledCliVersion;
|
|
79
86
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
87
|
+
const configuredRoots = readDaemonConfig(dir).trustedHqRoots;
|
|
88
|
+
const trustedRoots = [...new Set([
|
|
89
|
+
...(deps.hqRoot ? [deps.hqRoot] : []),
|
|
90
|
+
...(env.HQ_ROOT?.trim() ? [env.HQ_ROOT.trim()] : []),
|
|
91
|
+
...configuredRoots,
|
|
92
|
+
])];
|
|
80
93
|
ensureDaemonLog(dir);
|
|
81
94
|
const lockDeps = deps.pidLock ?? defaultPidLockDeps({ isProcessAlive: alive });
|
|
82
95
|
const lock = acquirePidLock(dir, lockDeps);
|
|
@@ -132,6 +145,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
132
145
|
return backfillHeldSessions({
|
|
133
146
|
workMeshRoot: meshRoot,
|
|
134
147
|
workContextRoot: ctxRoot,
|
|
148
|
+
hqRoots: trustedRoots,
|
|
135
149
|
limit: deps.heldBackfillBatchSize ?? HELD_BACKFILL_STARTUP_BATCH_SIZE,
|
|
136
150
|
env,
|
|
137
151
|
reconcile: (observation) => reconcileObservation(observation, {
|
|
@@ -223,15 +237,28 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
223
237
|
}
|
|
224
238
|
}
|
|
225
239
|
let stopped = false;
|
|
240
|
+
let restartRequested = false;
|
|
226
241
|
let flushInFlight = null;
|
|
227
242
|
let debounceHandle = null;
|
|
228
243
|
let flushIntervalHandle = null;
|
|
244
|
+
let versionIntervalHandle = null;
|
|
229
245
|
let boardIntervalHandle = null;
|
|
230
246
|
let transcriptIntervalHandle = null;
|
|
231
247
|
let watcher = null;
|
|
232
248
|
/** Shared with transcript watcher: sessionIds seen on the hook/spool path. */
|
|
233
249
|
const lastHookEventAt = new Map();
|
|
234
250
|
let transcriptWatcher = null;
|
|
251
|
+
const requestRestartForVersionDrift = () => {
|
|
252
|
+
if (stopped || restartRequested)
|
|
253
|
+
return restartRequested;
|
|
254
|
+
const installedVersion = installedCliVersion();
|
|
255
|
+
if (!hasCliVersionDrift(daemonVersion, installedVersion))
|
|
256
|
+
return false;
|
|
257
|
+
restartRequested = true;
|
|
258
|
+
log(dir, `cli upgraded ${daemonVersion} -> ${installedVersion}, restarting`);
|
|
259
|
+
exit(DAEMON_VERSION_DRIFT_EXIT_CODE);
|
|
260
|
+
return true;
|
|
261
|
+
};
|
|
235
262
|
if (emitPath && deps.enableTranscriptWatch !== false) {
|
|
236
263
|
const hqRoot = deps.hqRoot ?? env.HQ_ROOT?.trim() ?? undefined;
|
|
237
264
|
const getToken = deps.getToken ?? defaultGetToken;
|
|
@@ -273,7 +300,12 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
273
300
|
}
|
|
274
301
|
const doFlushCycle = async (reason, opts = {}) => {
|
|
275
302
|
// Refuse new cycles after stop, except the forced shutdown flush.
|
|
276
|
-
if (stopped && !opts.force)
|
|
303
|
+
if ((stopped || restartRequested) && !opts.force)
|
|
304
|
+
return;
|
|
305
|
+
// A long-lived daemon retains its loaded JS after a global CLI upgrade.
|
|
306
|
+
// The service unit resolves the binary again on restart, so exiting lets
|
|
307
|
+
// launchd/systemd bring up the upgraded implementation.
|
|
308
|
+
if (!opts.force && requestRestartForVersionDrift())
|
|
277
309
|
return;
|
|
278
310
|
if (flushInFlight) {
|
|
279
311
|
await flushInFlight;
|
|
@@ -405,6 +437,14 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
405
437
|
boardIntervalHandle = timers.setTimeout(boardTick, BOARD_REFRESH_INTERVAL_MS);
|
|
406
438
|
});
|
|
407
439
|
}, BOARD_REFRESH_INTERVAL_MS);
|
|
440
|
+
// Version drift is independent of the legacy spool flush. Direct-mode
|
|
441
|
+
// daemons have no flush cycle, so they still need the same periodic restart
|
|
442
|
+
// check after an in-place CLI upgrade.
|
|
443
|
+
versionIntervalHandle = timers.setTimeout(function versionTick() {
|
|
444
|
+
if (stopped || requestRestartForVersionDrift())
|
|
445
|
+
return;
|
|
446
|
+
versionIntervalHandle = timers.setTimeout(versionTick, FLUSH_INTERVAL_MS);
|
|
447
|
+
}, FLUSH_INTERVAL_MS);
|
|
408
448
|
const runTranscriptTick = async () => {
|
|
409
449
|
if (!transcriptWatcher || stopped)
|
|
410
450
|
return;
|
|
@@ -447,7 +487,7 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
447
487
|
}
|
|
448
488
|
// Initial pass. Board refresh runs in both modes; the flush + transcript
|
|
449
489
|
// scan are legacy emit-path only.
|
|
450
|
-
if (emitPath) {
|
|
490
|
+
if (!requestRestartForVersionDrift() && emitPath) {
|
|
451
491
|
void doFlushCycle("start");
|
|
452
492
|
}
|
|
453
493
|
void refreshFn().catch(() => undefined);
|
|
@@ -463,6 +503,8 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
463
503
|
timers.clearTimeout(debounceHandle);
|
|
464
504
|
if (flushIntervalHandle !== null)
|
|
465
505
|
timers.clearTimeout(flushIntervalHandle);
|
|
506
|
+
if (versionIntervalHandle !== null)
|
|
507
|
+
timers.clearTimeout(versionIntervalHandle);
|
|
466
508
|
if (boardIntervalHandle !== null)
|
|
467
509
|
timers.clearTimeout(boardIntervalHandle);
|
|
468
510
|
if (transcriptIntervalHandle !== null) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Version installed behind the entrypoint that launched this daemon. */
|
|
2
|
+
export declare function readInstalledCliVersion(entry?: string): string | null;
|
|
3
|
+
/** A missing/unreadable installed version is not proof of a safe restart. */
|
|
4
|
+
export declare function hasCliVersionDrift(daemonVersion: string, installedVersion: string | null | undefined): installedVersion is string;
|
|
5
|
+
//# sourceMappingURL=version-drift.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read the version of the CLI package currently selected by the daemon's
|
|
3
|
+
* launch entrypoint. The daemon keeps the version it was loaded with in
|
|
4
|
+
* memory; an npm/pnpm upgrade can repoint this entrypoint without restarting
|
|
5
|
+
* that process.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
function packageVersionAtOrAbove(entry) {
|
|
10
|
+
let dir;
|
|
11
|
+
try {
|
|
12
|
+
dir = path.dirname(fs.realpathSync(entry));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
// A CLI entrypoint is normally <package>/dist/index.js. Keep the walk
|
|
18
|
+
// bounded so a malformed entrypoint never traverses an entire filesystem.
|
|
19
|
+
for (let i = 0; i < 8; i += 1) {
|
|
20
|
+
const pkgPath = path.join(dir, "package.json");
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
23
|
+
if (parsed.name === "@indigoai-us/hq-cli" &&
|
|
24
|
+
typeof parsed.version === "string" &&
|
|
25
|
+
parsed.version.trim()) {
|
|
26
|
+
return parsed.version.trim();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// No package manifest at this level; continue walking upward.
|
|
31
|
+
}
|
|
32
|
+
const parent = path.dirname(dir);
|
|
33
|
+
if (parent === dir)
|
|
34
|
+
break;
|
|
35
|
+
dir = parent;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
/** Version installed behind the entrypoint that launched this daemon. */
|
|
40
|
+
export function readInstalledCliVersion(entry = process.argv[1]) {
|
|
41
|
+
return entry ? packageVersionAtOrAbove(entry) : null;
|
|
42
|
+
}
|
|
43
|
+
/** A missing/unreadable installed version is not proof of a safe restart. */
|
|
44
|
+
export function hasCliVersionDrift(daemonVersion, installedVersion) {
|
|
45
|
+
return Boolean(installedVersion && installedVersion !== daemonVersion);
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=version-drift.js.map
|
|
@@ -6,6 +6,8 @@ export declare const WORK_MESH_DIRNAME = "work-mesh";
|
|
|
6
6
|
export declare function workMeshRoot(home?: string, env?: NodeJS.ProcessEnv): string;
|
|
7
7
|
export declare function workMeshSpoolPath(root?: string): string;
|
|
8
8
|
export declare function workMeshHeldPath(root?: string): string;
|
|
9
|
+
/** Rotating cursor for bounded daemon-start held-event recovery. */
|
|
10
|
+
export declare function workMeshHeldBackfillCursorPath(root?: string): string;
|
|
9
11
|
export declare function workMeshDeadLetterPath(root?: string): string;
|
|
10
12
|
export declare function workMeshDroppedReceiptPath(root?: string): string;
|
|
11
13
|
/** Claim path: spool.<ts>.claimed (never reuse spool.jsonl for claim). */
|
|
@@ -17,6 +17,10 @@ export function workMeshSpoolPath(root) {
|
|
|
17
17
|
export function workMeshHeldPath(root) {
|
|
18
18
|
return path.join(root ?? workMeshRoot(), "held.jsonl");
|
|
19
19
|
}
|
|
20
|
+
/** Rotating cursor for bounded daemon-start held-event recovery. */
|
|
21
|
+
export function workMeshHeldBackfillCursorPath(root) {
|
|
22
|
+
return path.join(root ?? workMeshRoot(), "held-backfill-cursor.json");
|
|
23
|
+
}
|
|
20
24
|
export function workMeshDeadLetterPath(root) {
|
|
21
25
|
return path.join(root ?? workMeshRoot(), "dead-letter.jsonl");
|
|
22
26
|
}
|
|
@@ -10,7 +10,7 @@ import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOrigi
|
|
|
10
10
|
import { decisionFromCandidates, } from "./organize.js";
|
|
11
11
|
import { resolveProjectTask, shouldAskAfter } from "./project.js";
|
|
12
12
|
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "./repo-remote.js";
|
|
13
|
-
import { enqueueOutbox, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
|
|
13
|
+
import { enqueueOutbox, listOutboxOperations, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
|
|
14
14
|
import { mergeLocalSessionFields, readSessionState, writeSessionState, } from "./state.js";
|
|
15
15
|
function resultOf(parts) {
|
|
16
16
|
const out = {
|
|
@@ -293,12 +293,10 @@ export async function reconcileObservation(obs, deps) {
|
|
|
293
293
|
startedAt,
|
|
294
294
|
...(typeof toolWrites === "number" ? { toolWrites } : {}),
|
|
295
295
|
...(hasTurn ? { hasTurn: true } : {}),
|
|
296
|
-
// Preserve prior
|
|
296
|
+
// Preserve prior scope for visibility; its receipt belongs to the
|
|
297
|
+
// quarantined binding and must not suppress a later retry.
|
|
297
298
|
...(prior?.projectId ? { projectId: prior.projectId } : {}),
|
|
298
299
|
...(prior?.taskId ? { taskId: prior.taskId } : {}),
|
|
299
|
-
...(prior?.bindingEpisodeId
|
|
300
|
-
? { bindingEpisodeId: prior.bindingEpisodeId }
|
|
301
|
-
: {}),
|
|
302
300
|
...transcriptFields,
|
|
303
301
|
decision: {
|
|
304
302
|
decisionId: `conflict_${sessionId}`,
|
|
@@ -325,7 +323,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
325
323
|
askAfter: false,
|
|
326
324
|
...(correctionPath ? { correctionPath } : {}),
|
|
327
325
|
},
|
|
328
|
-
}, prior), deps.root);
|
|
326
|
+
}, prior, { preserveBindingEpisode: false }), deps.root);
|
|
329
327
|
return {
|
|
330
328
|
result: resultOf({
|
|
331
329
|
kind: "company_conflict",
|
|
@@ -491,6 +489,69 @@ export async function reconcileObservation(obs, deps) {
|
|
|
491
489
|
prior.bindingEpisodeId
|
|
492
490
|
? "needs_project"
|
|
493
491
|
: registerClassification;
|
|
492
|
+
// Re-observing an already acknowledged binding is normal for hooks and the
|
|
493
|
+
// resident daemon. A fresh clientOperationId must not turn that observation
|
|
494
|
+
// into a second semantic register operation: the prior receipt is the stable
|
|
495
|
+
// identity for this exact company/project/task binding.
|
|
496
|
+
const acknowledgedPrior = prior?.bindingEpisodeId &&
|
|
497
|
+
prior.companyUid === resolvedCompany.uid &&
|
|
498
|
+
prior.companySlug === resolvedCompany.slug &&
|
|
499
|
+
prior.projectId === projectId &&
|
|
500
|
+
prior.taskId === taskId &&
|
|
501
|
+
listOutboxOperations(deps.root).some((operation) => operation.kind === "register" &&
|
|
502
|
+
operation.delivery === "acked" &&
|
|
503
|
+
operation.sessionId === sessionId &&
|
|
504
|
+
operation.receiptId === prior.bindingEpisodeId &&
|
|
505
|
+
operation.companyUid === resolvedCompany.uid &&
|
|
506
|
+
operation.companySlug === resolvedCompany.slug &&
|
|
507
|
+
operation.projectId === projectId &&
|
|
508
|
+
operation.taskId === taskId)
|
|
509
|
+
? prior
|
|
510
|
+
: null;
|
|
511
|
+
if (acknowledgedPrior) {
|
|
512
|
+
// Activity is local progress, not binding identity. Retain the receipt
|
|
513
|
+
// without another register while carrying hook counters forward so the
|
|
514
|
+
// normal clarification threshold still advances.
|
|
515
|
+
writeSessionState(mergeLocalSessionFields({
|
|
516
|
+
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
517
|
+
sessionId,
|
|
518
|
+
companyUid: acknowledgedPrior.companyUid,
|
|
519
|
+
companySlug: acknowledgedPrior.companySlug,
|
|
520
|
+
contextStatus: stateClassification,
|
|
521
|
+
projectId,
|
|
522
|
+
taskId,
|
|
523
|
+
bindingEpisodeId: acknowledgedPrior.bindingEpisodeId,
|
|
524
|
+
updatedAt: nowIso,
|
|
525
|
+
startedAt,
|
|
526
|
+
...(typeof toolWrites === "number" ? { toolWrites } : {}),
|
|
527
|
+
...(hasTurn ? { hasTurn: true } : {}),
|
|
528
|
+
...(decision ? { decision } : {}),
|
|
529
|
+
...transcriptFields,
|
|
530
|
+
}, prior), deps.root);
|
|
531
|
+
const kind = registerClassification === "bound"
|
|
532
|
+
? "bound"
|
|
533
|
+
: registerClassification === "needs_task"
|
|
534
|
+
? "needs_task"
|
|
535
|
+
: registerClassification === "needs_project"
|
|
536
|
+
? "needs_project"
|
|
537
|
+
: "queued";
|
|
538
|
+
diagnostics.push(`binding unchanged; retained receipt=${acknowledgedPrior.bindingEpisodeId}`);
|
|
539
|
+
return {
|
|
540
|
+
result: resultOf({
|
|
541
|
+
kind,
|
|
542
|
+
classification: registerClassification,
|
|
543
|
+
delivery: "acked",
|
|
544
|
+
sessionId,
|
|
545
|
+
clientOperationId: obs.clientOperationId,
|
|
546
|
+
companyUid: resolvedCompany.uid,
|
|
547
|
+
projectId,
|
|
548
|
+
taskId,
|
|
549
|
+
bindingEpisodeId: acknowledgedPrior.bindingEpisodeId,
|
|
550
|
+
}),
|
|
551
|
+
exitCode: EXIT_OK,
|
|
552
|
+
diagnostics,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
494
555
|
let outboxOp;
|
|
495
556
|
try {
|
|
496
557
|
outboxOp = enqueueOutbox({
|