@indigoai-us/hq-cli 5.169.0 → 5.171.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/CHANGELOG.md +23 -0
- package/dist/commands/bot.js +11 -0
- package/dist/lib/bot/cli-update.d.ts +146 -0
- package/dist/lib/bot/cli-update.js +278 -0
- package/dist/lib/bot/run.d.ts +12 -0
- package/dist/lib/bot/run.js +42 -4
- package/dist/lib/bot/status.d.ts +2 -0
- package/dist/lib/mesh/live/flush.js +160 -11
- package/dist/lib/work-context/company-rebind.d.ts +23 -0
- package/dist/lib/work-context/company-rebind.js +59 -0
- package/dist/lib/work-context/company.d.ts +3 -2
- package/dist/lib/work-context/company.js +14 -6
- package/dist/lib/work-context/index.d.ts +1 -0
- package/dist/lib/work-context/index.js +1 -0
- package/dist/lib/work-context/reconcile.js +68 -5
- package/dist/lib/work-context/state.d.ts +11 -0
- package/dist/lib/work-context/state.js +10 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.171.0] — 2026-09-24
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Local bots (`hq bot run`) now restart onto an updated hq-cli install. A
|
|
10
|
+
running bot checks the installed package behind its launch path every
|
|
11
|
+
minute. When a complete, valid, different version is seen on two checks in a
|
|
12
|
+
row, it stops taking new inbox items, lets running turns finish (queued items
|
|
13
|
+
stay un-acked for the next process), logs
|
|
14
|
+
`hq-cli updated <old> -> <new>; restarting to load it`, and exits 75 so
|
|
15
|
+
launchd, systemd, or `hq daemon` starts it again. A bot with no supervisor
|
|
16
|
+
starts its own replacement first. The new process logs its version at
|
|
17
|
+
startup and whether the update converged. A restart that comes back on the
|
|
18
|
+
old version blocks that target for 6 hours, and update restarts are capped
|
|
19
|
+
at 4 per hour. `hq bot status` shows the hq-cli version a bot is running.
|
|
20
|
+
Set `HQ_BOT_NO_CLI_UPDATE_RESTART=1` to turn this off.
|
|
21
|
+
|
|
22
|
+
## [5.170.0] — 2026-09-24
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Switching the HQ session company (including `hq-session.sh set company_slug`) clears the Work Mesh project and task, posts `session_end` to the previous company, and holds a personal target as `needs_company`.
|
|
27
|
+
|
|
5
28
|
## [5.169.0] — 2026-09-24
|
|
6
29
|
|
|
7
30
|
### Fixed
|
package/dist/commands/bot.js
CHANGED
|
@@ -25,6 +25,7 @@ import { hasPromotionHold, promotionHoldSummary } from "../lib/bot/promotion-hol
|
|
|
25
25
|
* list (online flag), and rm. Secrets are never printed.
|
|
26
26
|
*/
|
|
27
27
|
import { promoteLocalBot } from "../lib/bot/promote.js";
|
|
28
|
+
import { CLI_UPDATE_DISABLE_ENV, createCliUpdateWatcher, handOffToUpdatedCli } from "../lib/bot/cli-update.js";
|
|
28
29
|
import { createVaultObjectDownloader } from "../lib/agent-kit/attachment-download.js";
|
|
29
30
|
import { addBotToCompanies, describeCompanyFailures, removeBotFromCompanies } from "./bot-companies.js";
|
|
30
31
|
import { registerBotContinuityCommand } from "./bot-continuity.js";
|
|
@@ -743,6 +744,14 @@ async function runBotResident(nameArg) {
|
|
|
743
744
|
// Chat attachments are fetched with the bot's own machine identity against
|
|
744
745
|
// the same API its inbox and posts use (BotApi has no baseUrl override either).
|
|
745
746
|
downloadAttachment: createVaultObjectDownloader({ getToken: token }),
|
|
747
|
+
// Reload onto an updated hq-cli: the watched path is the script this
|
|
748
|
+
// process was launched with, which is what its supervisor launches next.
|
|
749
|
+
...(process.env[CLI_UPDATE_DISABLE_ENV] === "1"
|
|
750
|
+
? {}
|
|
751
|
+
: { cliUpdate: createCliUpdateWatcher({ dir, entry: process.argv[1] ?? null }) }),
|
|
752
|
+
onCliUpdateRestart: (_update, log) => {
|
|
753
|
+
handOffToUpdatedCli(name, log, { spawnReplacement: () => spawnDetachedRun(name, dir) });
|
|
754
|
+
},
|
|
746
755
|
});
|
|
747
756
|
await handle.done;
|
|
748
757
|
void name;
|
|
@@ -796,6 +805,8 @@ async function runBotStatus(nameArg, opts) {
|
|
|
796
805
|
console.log(` creds: ${creds ? `present for ${creds.entityUid}` : chalk.red("missing")}`);
|
|
797
806
|
if (status) {
|
|
798
807
|
console.log(` started: ${status.startedAt} restarts: ${status.restarts} replies: ${status.repliesSent}`);
|
|
808
|
+
if (status.cliVersion)
|
|
809
|
+
console.log(` hq-cli: ${status.cliVersion}`);
|
|
799
810
|
if (status.runtimeSignIn?.state === "expired") {
|
|
800
811
|
const tool = runtimeDisplayName(status.runtimeSignIn.runtime);
|
|
801
812
|
console.log(` ${chalk.yellow(`${tool} needs to sign in again`)} — sign in from HQ, or run \`${status.runtimeSignIn.runtime} ${status.runtimeSignIn.runtime === "claude" ? "auth login" : "login"}\`, then \`hq bot restart ${name}\``);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A resident bot notices that the hq-cli install it was started from has been
|
|
3
|
+
* updated on disk, and restarts itself onto the new code.
|
|
4
|
+
*
|
|
5
|
+
* `hq bot run <name>` loads the CLI once and runs for days. The install under
|
|
6
|
+
* it moves on (the attended self-updater, the `hq daemon` updater, a manual
|
|
7
|
+
* `npm i -g`), but nothing reloaded a running bot: on 2026-09-24 the bot
|
|
8
|
+
* dr-love was still running code from 2026-09-19 while its install had moved
|
|
9
|
+
* to 5.163.0, so a shipped attachment fix never reached it.
|
|
10
|
+
*
|
|
11
|
+
* The watcher re-resolves the entry script the process was launched with
|
|
12
|
+
* (process.argv[1], the same path launchd / systemd / `hq daemon` will launch
|
|
13
|
+
* again) to its package.json on a timer. Once a complete, valid, different
|
|
14
|
+
* version is seen on consecutive checks, the run loop stops taking new inbox
|
|
15
|
+
* items, lets the running turns finish, and exits with
|
|
16
|
+
* CLI_UPDATE_RESTART_EXIT_CODE so its supervisor starts it again on the new
|
|
17
|
+
* code (or, with no supervisor, starts its own replacement first).
|
|
18
|
+
*
|
|
19
|
+
* Convergence (policy hq-self-updater-must-verify-convergence-after-install):
|
|
20
|
+
* the restart is recorded in <botDir>/cli-update.json before the exit. The next
|
|
21
|
+
* process checks the version it actually loaded against that record: a match
|
|
22
|
+
* is logged as converged; a process that came back on the OLD version blocks
|
|
23
|
+
* that target for CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS instead of restarting
|
|
24
|
+
* again. Restarts are also capped per window, so an install that keeps
|
|
25
|
+
* flipping cannot turn into a restart loop.
|
|
26
|
+
*/
|
|
27
|
+
import type { BotLogger } from "./log.js";
|
|
28
|
+
/**
|
|
29
|
+
* Exit code for "restart me onto the updated CLI". Non-zero on purpose: every
|
|
30
|
+
* supervisor a bot runs under restarts only a failed exit — launchd
|
|
31
|
+
* KeepAlive{SuccessfulExit:false}, systemd Restart=on-failure, and the
|
|
32
|
+
* `hq daemon` "on-failure" bot units — while a clean exit 0 (a stopped or
|
|
33
|
+
* disabled bot) stays down. Same value as the daemon's RESTART_EXIT_CODE.
|
|
34
|
+
*/
|
|
35
|
+
export declare const CLI_UPDATE_RESTART_EXIT_CODE = 75;
|
|
36
|
+
/** How often the installed package.json is read. */
|
|
37
|
+
export declare const CLI_UPDATE_CHECK_INTERVAL_MS = 60000;
|
|
38
|
+
/** Consecutive checks that must agree on the new version before acting. */
|
|
39
|
+
export declare const CLI_UPDATE_CONFIRMATIONS = 2;
|
|
40
|
+
/** At most this many update restarts per window, whatever the versions. */
|
|
41
|
+
export declare const CLI_UPDATE_RESTART_LIMIT = 4;
|
|
42
|
+
export declare const CLI_UPDATE_RESTART_WINDOW_MS: number;
|
|
43
|
+
/** A restart that came back on the old version blocks that target this long. */
|
|
44
|
+
export declare const CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS: number;
|
|
45
|
+
/** Set to "1" to turn the watcher off (development runs). */
|
|
46
|
+
export declare const CLI_UPDATE_DISABLE_ENV = "HQ_BOT_NO_CLI_UPDATE_RESTART";
|
|
47
|
+
export declare const CLI_UPDATE_LEDGER_FILE = "cli-update.json";
|
|
48
|
+
export type InstalledCliRead = {
|
|
49
|
+
ok: true;
|
|
50
|
+
version: string;
|
|
51
|
+
packageRoot: string;
|
|
52
|
+
} | {
|
|
53
|
+
ok: false;
|
|
54
|
+
reason: string;
|
|
55
|
+
packageRoot?: string;
|
|
56
|
+
};
|
|
57
|
+
export interface InstalledCliFs {
|
|
58
|
+
realpathSync(p: string): string;
|
|
59
|
+
readFileSync(p: string, encoding: "utf8"): string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The hq-cli version installed behind `entry`, resolved the way the next launch
|
|
63
|
+
* of the same command line will resolve it: follow the symlinks now (a pnpm or
|
|
64
|
+
* versioned layout moves the link, npm rewrites the directory in place), then
|
|
65
|
+
* walk up to the package.json that names this CLI. Anything short of a
|
|
66
|
+
* readable manifest with a valid semver version is not a version.
|
|
67
|
+
*/
|
|
68
|
+
export declare function readInstalledCli(entry: string, io?: InstalledCliFs): InstalledCliRead;
|
|
69
|
+
export interface CliUpdate {
|
|
70
|
+
from: string;
|
|
71
|
+
to: string;
|
|
72
|
+
packageRoot: string;
|
|
73
|
+
}
|
|
74
|
+
interface CliUpdateLedger {
|
|
75
|
+
v: 1;
|
|
76
|
+
/** Written just before an update restart; settled by the next process. */
|
|
77
|
+
pending?: {
|
|
78
|
+
from: string;
|
|
79
|
+
to: string;
|
|
80
|
+
at: string;
|
|
81
|
+
};
|
|
82
|
+
/** ISO times of recent update restarts (for the per-window cap). */
|
|
83
|
+
restarts: string[];
|
|
84
|
+
/** A target that did not converge, and when it may be tried again. */
|
|
85
|
+
blocked?: {
|
|
86
|
+
version: string;
|
|
87
|
+
until: string;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
export declare function readCliUpdateLedger(dir: string): CliUpdateLedger;
|
|
91
|
+
/** What the run loop needs from the watcher (injectable in tests). */
|
|
92
|
+
export interface CliUpdateWatch {
|
|
93
|
+
/** The version of the code this process loaded. */
|
|
94
|
+
readonly runningVersion: string;
|
|
95
|
+
/** Settle the previous restart (converged or not) and say what is watched. */
|
|
96
|
+
startup(log: BotLogger): void;
|
|
97
|
+
/** A confirmed update to restart onto, or null. Cheap; rate-limits itself. */
|
|
98
|
+
poll(log: BotLogger): CliUpdate | null;
|
|
99
|
+
/** Record the restart about to happen, before the process exits. */
|
|
100
|
+
recordRestart(update: CliUpdate): void;
|
|
101
|
+
}
|
|
102
|
+
export interface CliUpdateWatcherOptions {
|
|
103
|
+
dir: string;
|
|
104
|
+
/** The script this process was launched with (process.argv[1]); null = do not watch. */
|
|
105
|
+
entry: string | null;
|
|
106
|
+
runningVersion?: string;
|
|
107
|
+
now?: () => number;
|
|
108
|
+
read?: (entry: string) => InstalledCliRead;
|
|
109
|
+
/** True while an installer is provably rewriting the package dir. */
|
|
110
|
+
rewriteInProgress?: (packageRoot: string) => boolean;
|
|
111
|
+
checkIntervalMs?: number;
|
|
112
|
+
confirmations?: number;
|
|
113
|
+
restartLimit?: number;
|
|
114
|
+
restartWindowMs?: number;
|
|
115
|
+
cooldownMs?: number;
|
|
116
|
+
}
|
|
117
|
+
export declare function createCliUpdateWatcher(opts: CliUpdateWatcherOptions): CliUpdateWatch;
|
|
118
|
+
/**
|
|
119
|
+
* Who starts this bot again after it exits.
|
|
120
|
+
*
|
|
121
|
+
* - "hq-daemon": `hq daemon` sets HQ_DAEMON_UNIT=bots:<name> on its children;
|
|
122
|
+
* the unit is "on-failure" (supervisor.ts).
|
|
123
|
+
* - "launchd": launchd sets XPC_SERVICE_NAME to the job label for every job it
|
|
124
|
+
* spawns, including LaunchAgents installed before this code existed.
|
|
125
|
+
* - "systemd": the unit `hq-bot-<name>.service` shows up in the process cgroup.
|
|
126
|
+
* - "none": `hq bot start` without a service (Windows, --no-daemon, a Linux
|
|
127
|
+
* unit never enabled) or a foreground run. Nobody restarts this process.
|
|
128
|
+
*/
|
|
129
|
+
export type BotSupervisor = "hq-daemon" | "launchd" | "systemd" | "none";
|
|
130
|
+
export declare function detectBotSupervisor(name: string, opts?: {
|
|
131
|
+
env?: NodeJS.ProcessEnv;
|
|
132
|
+
platform?: NodeJS.Platform;
|
|
133
|
+
readCgroup?: () => string | null;
|
|
134
|
+
}): BotSupervisor;
|
|
135
|
+
/**
|
|
136
|
+
* The last step of an update restart, run after the pid lock is released and
|
|
137
|
+
* just before the exit. A supervised bot only has to exit; an unsupervised one
|
|
138
|
+
* starts its replacement on the same entry script (now the new code) first,
|
|
139
|
+
* because nothing else will.
|
|
140
|
+
*/
|
|
141
|
+
export declare function handOffToUpdatedCli(name: string, log: BotLogger, deps: {
|
|
142
|
+
supervisor?: BotSupervisor;
|
|
143
|
+
spawnReplacement: () => number;
|
|
144
|
+
}): BotSupervisor;
|
|
145
|
+
export {};
|
|
146
|
+
//# sourceMappingURL=cli-update.d.ts.map
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A resident bot notices that the hq-cli install it was started from has been
|
|
3
|
+
* updated on disk, and restarts itself onto the new code.
|
|
4
|
+
*
|
|
5
|
+
* `hq bot run <name>` loads the CLI once and runs for days. The install under
|
|
6
|
+
* it moves on (the attended self-updater, the `hq daemon` updater, a manual
|
|
7
|
+
* `npm i -g`), but nothing reloaded a running bot: on 2026-09-24 the bot
|
|
8
|
+
* dr-love was still running code from 2026-09-19 while its install had moved
|
|
9
|
+
* to 5.163.0, so a shipped attachment fix never reached it.
|
|
10
|
+
*
|
|
11
|
+
* The watcher re-resolves the entry script the process was launched with
|
|
12
|
+
* (process.argv[1], the same path launchd / systemd / `hq daemon` will launch
|
|
13
|
+
* again) to its package.json on a timer. Once a complete, valid, different
|
|
14
|
+
* version is seen on consecutive checks, the run loop stops taking new inbox
|
|
15
|
+
* items, lets the running turns finish, and exits with
|
|
16
|
+
* CLI_UPDATE_RESTART_EXIT_CODE so its supervisor starts it again on the new
|
|
17
|
+
* code (or, with no supervisor, starts its own replacement first).
|
|
18
|
+
*
|
|
19
|
+
* Convergence (policy hq-self-updater-must-verify-convergence-after-install):
|
|
20
|
+
* the restart is recorded in <botDir>/cli-update.json before the exit. The next
|
|
21
|
+
* process checks the version it actually loaded against that record: a match
|
|
22
|
+
* is logged as converged; a process that came back on the OLD version blocks
|
|
23
|
+
* that target for CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS instead of restarting
|
|
24
|
+
* again. Restarts are also capped per window, so an install that keeps
|
|
25
|
+
* flipping cannot turn into a restart loop.
|
|
26
|
+
*/
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import semver from "semver";
|
|
30
|
+
import { CLI_NAME, CLI_VERSION } from "../../cli-version.js";
|
|
31
|
+
import { installRewriteInProgress } from "../../utils/install-tree-torn.js";
|
|
32
|
+
import { updateLockPath } from "../../utils/update-lock.js";
|
|
33
|
+
import { launchdLabel } from "./paths.js";
|
|
34
|
+
/**
|
|
35
|
+
* Exit code for "restart me onto the updated CLI". Non-zero on purpose: every
|
|
36
|
+
* supervisor a bot runs under restarts only a failed exit — launchd
|
|
37
|
+
* KeepAlive{SuccessfulExit:false}, systemd Restart=on-failure, and the
|
|
38
|
+
* `hq daemon` "on-failure" bot units — while a clean exit 0 (a stopped or
|
|
39
|
+
* disabled bot) stays down. Same value as the daemon's RESTART_EXIT_CODE.
|
|
40
|
+
*/
|
|
41
|
+
export const CLI_UPDATE_RESTART_EXIT_CODE = 75;
|
|
42
|
+
/** How often the installed package.json is read. */
|
|
43
|
+
export const CLI_UPDATE_CHECK_INTERVAL_MS = 60_000;
|
|
44
|
+
/** Consecutive checks that must agree on the new version before acting. */
|
|
45
|
+
export const CLI_UPDATE_CONFIRMATIONS = 2;
|
|
46
|
+
/** At most this many update restarts per window, whatever the versions. */
|
|
47
|
+
export const CLI_UPDATE_RESTART_LIMIT = 4;
|
|
48
|
+
export const CLI_UPDATE_RESTART_WINDOW_MS = 60 * 60_000;
|
|
49
|
+
/** A restart that came back on the old version blocks that target this long. */
|
|
50
|
+
export const CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS = 6 * 60 * 60_000;
|
|
51
|
+
/** Set to "1" to turn the watcher off (development runs). */
|
|
52
|
+
export const CLI_UPDATE_DISABLE_ENV = "HQ_BOT_NO_CLI_UPDATE_RESTART";
|
|
53
|
+
export const CLI_UPDATE_LEDGER_FILE = "cli-update.json";
|
|
54
|
+
/**
|
|
55
|
+
* The hq-cli version installed behind `entry`, resolved the way the next launch
|
|
56
|
+
* of the same command line will resolve it: follow the symlinks now (a pnpm or
|
|
57
|
+
* versioned layout moves the link, npm rewrites the directory in place), then
|
|
58
|
+
* walk up to the package.json that names this CLI. Anything short of a
|
|
59
|
+
* readable manifest with a valid semver version is not a version.
|
|
60
|
+
*/
|
|
61
|
+
export function readInstalledCli(entry, io = fs) {
|
|
62
|
+
let real;
|
|
63
|
+
try {
|
|
64
|
+
real = io.realpathSync(entry);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return { ok: false, reason: `${entry} does not resolve` };
|
|
68
|
+
}
|
|
69
|
+
let dir = path.dirname(real);
|
|
70
|
+
for (let i = 0; i < 8; i++) {
|
|
71
|
+
const manifest = path.join(dir, "package.json");
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = io.readFileSync(manifest, "utf8");
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
raw = null;
|
|
78
|
+
}
|
|
79
|
+
if (raw !== null) {
|
|
80
|
+
let parsed;
|
|
81
|
+
try {
|
|
82
|
+
parsed = JSON.parse(raw);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return { ok: false, reason: `${manifest} is not valid JSON`, packageRoot: dir };
|
|
86
|
+
}
|
|
87
|
+
if (parsed.name === CLI_NAME) {
|
|
88
|
+
const version = typeof parsed.version === "string" ? semver.valid(parsed.version) : null;
|
|
89
|
+
if (!version)
|
|
90
|
+
return { ok: false, reason: `${manifest} has no valid version`, packageRoot: dir };
|
|
91
|
+
return { ok: true, version, packageRoot: dir };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const parent = path.dirname(dir);
|
|
95
|
+
if (parent === dir)
|
|
96
|
+
break;
|
|
97
|
+
dir = parent;
|
|
98
|
+
}
|
|
99
|
+
return { ok: false, reason: `no ${CLI_NAME} package.json above ${real}` };
|
|
100
|
+
}
|
|
101
|
+
function ledgerPath(dir) {
|
|
102
|
+
return path.join(dir, CLI_UPDATE_LEDGER_FILE);
|
|
103
|
+
}
|
|
104
|
+
export function readCliUpdateLedger(dir) {
|
|
105
|
+
try {
|
|
106
|
+
const raw = JSON.parse(fs.readFileSync(ledgerPath(dir), "utf8"));
|
|
107
|
+
return {
|
|
108
|
+
v: 1,
|
|
109
|
+
restarts: Array.isArray(raw.restarts) ? raw.restarts.filter((s) => typeof s === "string") : [],
|
|
110
|
+
...(raw.pending && typeof raw.pending.from === "string" && typeof raw.pending.to === "string" ? { pending: raw.pending } : {}),
|
|
111
|
+
...(raw.blocked && typeof raw.blocked.version === "string" && typeof raw.blocked.until === "string" ? { blocked: raw.blocked } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return { v: 1, restarts: [] };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function writeCliUpdateLedger(dir, ledger) {
|
|
119
|
+
const target = ledgerPath(dir);
|
|
120
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
121
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
122
|
+
fs.writeFileSync(tmp, `${JSON.stringify(ledger, null, 2)}\n`, { mode: 0o600 });
|
|
123
|
+
fs.renameSync(tmp, target);
|
|
124
|
+
}
|
|
125
|
+
export function createCliUpdateWatcher(opts) {
|
|
126
|
+
const runningVersion = opts.runningVersion ?? CLI_VERSION;
|
|
127
|
+
const now = opts.now ?? Date.now;
|
|
128
|
+
const read = opts.read ?? ((entry) => readInstalledCli(entry));
|
|
129
|
+
const rewriteInProgress = opts.rewriteInProgress ?? ((packageRoot) => installRewriteInProgress({ packageRoot, lockPath: updateLockPath() }));
|
|
130
|
+
const checkIntervalMs = opts.checkIntervalMs ?? CLI_UPDATE_CHECK_INTERVAL_MS;
|
|
131
|
+
const confirmations = Math.max(1, opts.confirmations ?? CLI_UPDATE_CONFIRMATIONS);
|
|
132
|
+
const restartLimit = Math.max(1, opts.restartLimit ?? CLI_UPDATE_RESTART_LIMIT);
|
|
133
|
+
const restartWindowMs = opts.restartWindowMs ?? CLI_UPDATE_RESTART_WINDOW_MS;
|
|
134
|
+
const cooldownMs = opts.cooldownMs ?? CLI_UPDATE_NOT_CONVERGED_COOLDOWN_MS;
|
|
135
|
+
const { dir, entry } = opts;
|
|
136
|
+
let nextCheckAt = now() + checkIntervalMs;
|
|
137
|
+
let candidate = null;
|
|
138
|
+
let seen = 0;
|
|
139
|
+
/** Last "why not" line, so a condition that persists is logged once. */
|
|
140
|
+
let lastNote = null;
|
|
141
|
+
const note = (log, level, message) => {
|
|
142
|
+
if (message === lastNote)
|
|
143
|
+
return;
|
|
144
|
+
lastNote = message;
|
|
145
|
+
log(level, message);
|
|
146
|
+
};
|
|
147
|
+
const recentRestarts = (ledger, at) => ledger.restarts.filter((iso) => {
|
|
148
|
+
const t = Date.parse(iso);
|
|
149
|
+
return Number.isFinite(t) && at - t < restartWindowMs;
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
runningVersion,
|
|
153
|
+
startup(log) {
|
|
154
|
+
if (!entry) {
|
|
155
|
+
log("info", "not watching for hq-cli updates (no entry script)");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const ledger = readCliUpdateLedger(dir);
|
|
159
|
+
const pending = ledger.pending;
|
|
160
|
+
if (pending) {
|
|
161
|
+
delete ledger.pending;
|
|
162
|
+
if (runningVersion === pending.to) {
|
|
163
|
+
log("info", `hq-cli update converged: now running ${runningVersion} (was ${pending.from})`);
|
|
164
|
+
if (ledger.blocked?.version === runningVersion)
|
|
165
|
+
delete ledger.blocked;
|
|
166
|
+
}
|
|
167
|
+
else if (runningVersion === pending.from) {
|
|
168
|
+
const until = new Date(now() + cooldownMs).toISOString();
|
|
169
|
+
ledger.blocked = { version: pending.to, until };
|
|
170
|
+
log("error", `restarted to load hq-cli ${pending.to} but this process still loaded ${runningVersion}; ` +
|
|
171
|
+
`not restarting for ${pending.to} again until ${until} (check which install ${entry} resolves to)`);
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
log("info", `restarted for hq-cli ${pending.to}; now running ${runningVersion}`);
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
writeCliUpdateLedger(dir, ledger);
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
log("warn", `could not update ${CLI_UPDATE_LEDGER_FILE}: ${err instanceof Error ? err.message : String(err)}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
log("info", `watching ${entry} for hq-cli updates (running ${runningVersion})`);
|
|
184
|
+
},
|
|
185
|
+
poll(log) {
|
|
186
|
+
if (!entry)
|
|
187
|
+
return null;
|
|
188
|
+
const at = now();
|
|
189
|
+
if (at < nextCheckAt)
|
|
190
|
+
return null;
|
|
191
|
+
nextCheckAt = at + checkIntervalMs;
|
|
192
|
+
const installed = read(entry);
|
|
193
|
+
if (!installed.ok) {
|
|
194
|
+
candidate = null;
|
|
195
|
+
seen = 0;
|
|
196
|
+
note(log, "warn", `hq-cli install not readable, not restarting: ${installed.reason}`);
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (installed.version === runningVersion) {
|
|
200
|
+
candidate = null;
|
|
201
|
+
seen = 0;
|
|
202
|
+
lastNote = null;
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (rewriteInProgress(installed.packageRoot)) {
|
|
206
|
+
candidate = null;
|
|
207
|
+
seen = 0;
|
|
208
|
+
note(log, "info", `hq-cli ${installed.version} is still being installed; waiting for it to finish`);
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
if (candidate === installed.version)
|
|
212
|
+
seen += 1;
|
|
213
|
+
else {
|
|
214
|
+
candidate = installed.version;
|
|
215
|
+
seen = 1;
|
|
216
|
+
}
|
|
217
|
+
if (seen < confirmations) {
|
|
218
|
+
note(log, "info", `hq-cli ${installed.version} is installed (running ${runningVersion}); confirming before restarting`);
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
const ledger = readCliUpdateLedger(dir);
|
|
222
|
+
if (ledger.blocked?.version === installed.version && at < Date.parse(ledger.blocked.until)) {
|
|
223
|
+
note(log, "warn", `not restarting for hq-cli ${installed.version}: an earlier restart for it did not load it (retry after ${ledger.blocked.until})`);
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
if (recentRestarts(ledger, at).length >= restartLimit) {
|
|
227
|
+
note(log, "warn", `not restarting for hq-cli ${installed.version}: already restarted ${restartLimit} times for updates in the last ${Math.round(restartWindowMs / 60_000)} minutes`);
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
return { from: runningVersion, to: installed.version, packageRoot: installed.packageRoot };
|
|
231
|
+
},
|
|
232
|
+
recordRestart(update) {
|
|
233
|
+
const at = now();
|
|
234
|
+
const ledger = readCliUpdateLedger(dir);
|
|
235
|
+
ledger.restarts = [...recentRestarts(ledger, at), new Date(at).toISOString()];
|
|
236
|
+
ledger.pending = { from: update.from, to: update.to, at: new Date(at).toISOString() };
|
|
237
|
+
writeCliUpdateLedger(dir, ledger);
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
export function detectBotSupervisor(name, opts = {}) {
|
|
242
|
+
const env = opts.env ?? process.env;
|
|
243
|
+
const platform = opts.platform ?? process.platform;
|
|
244
|
+
if (env.HQ_DAEMON_UNIT === `bots:${name}`)
|
|
245
|
+
return "hq-daemon";
|
|
246
|
+
if (platform === "darwin" && env.XPC_SERVICE_NAME === launchdLabel(name))
|
|
247
|
+
return "launchd";
|
|
248
|
+
if (platform === "linux") {
|
|
249
|
+
const readCgroup = opts.readCgroup ??
|
|
250
|
+
(() => {
|
|
251
|
+
try {
|
|
252
|
+
return fs.readFileSync("/proc/self/cgroup", "utf8");
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
if (readCgroup()?.includes(`/hq-bot-${name}.service`))
|
|
259
|
+
return "systemd";
|
|
260
|
+
}
|
|
261
|
+
return "none";
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The last step of an update restart, run after the pid lock is released and
|
|
265
|
+
* just before the exit. A supervised bot only has to exit; an unsupervised one
|
|
266
|
+
* starts its replacement on the same entry script (now the new code) first,
|
|
267
|
+
* because nothing else will.
|
|
268
|
+
*/
|
|
269
|
+
export function handOffToUpdatedCli(name, log, deps) {
|
|
270
|
+
const supervisor = deps.supervisor ?? detectBotSupervisor(name);
|
|
271
|
+
if (supervisor !== "none") {
|
|
272
|
+
log("info", `exiting for ${supervisor} to start the updated hq-cli`);
|
|
273
|
+
return supervisor;
|
|
274
|
+
}
|
|
275
|
+
log("info", `no supervisor; started replacement pid ${deps.spawnReplacement()}`);
|
|
276
|
+
return supervisor;
|
|
277
|
+
}
|
|
278
|
+
//# sourceMappingURL=cli-update.js.map
|
package/dist/lib/bot/run.d.ts
CHANGED
|
@@ -53,6 +53,7 @@ import { type BotRuntime, type RuntimeTurnInput, type RuntimeTurnResult } from "
|
|
|
53
53
|
import type { VaultObjectDownloader } from "../agent-kit/attachment-download.js";
|
|
54
54
|
import { type HeartbeatAgentUidSource, type HeartbeatCredsSource, type HeartbeatIdentityMismatchReporter } from "../heartbeat-identity.js";
|
|
55
55
|
import { type BotState, type BotStatusFile } from "./status.js";
|
|
56
|
+
import { type CliUpdate, type CliUpdateWatch } from "./cli-update.js";
|
|
56
57
|
export declare const INBOX_POLL_INTERVAL_MS = 2000;
|
|
57
58
|
export declare const HEARTBEAT_INTERVAL_MS = 30000;
|
|
58
59
|
/** First 429/5xx wait when Retry-After is absent (inbox polls at 2s). */
|
|
@@ -150,6 +151,17 @@ export interface BotRunDeps {
|
|
|
150
151
|
agentUidSource?: HeartbeatAgentUidSource;
|
|
151
152
|
credsSource?: HeartbeatCredsSource;
|
|
152
153
|
reportIdentityMismatch?: HeartbeatIdentityMismatchReporter;
|
|
154
|
+
/**
|
|
155
|
+
* Watches the hq-cli install this process was started from (cli-update.ts).
|
|
156
|
+
* When it reports an update, the bot stops taking inbox items, lets running
|
|
157
|
+
* turns finish, and exits CLI_UPDATE_RESTART_EXIT_CODE. Unset = no watch.
|
|
158
|
+
*/
|
|
159
|
+
cliUpdate?: CliUpdateWatch;
|
|
160
|
+
/**
|
|
161
|
+
* Called after the pid lock is released and just before the update-restart
|
|
162
|
+
* exit: the place to start a replacement when no supervisor will.
|
|
163
|
+
*/
|
|
164
|
+
onCliUpdateRestart?: (update: CliUpdate, log: BotLogger) => void;
|
|
153
165
|
}
|
|
154
166
|
export declare const KICKOFF_MESSAGE_ID = "kickoff";
|
|
155
167
|
/**
|
package/dist/lib/bot/run.js
CHANGED
|
@@ -74,6 +74,8 @@ import { HQ_BOT_AGENT_UID_ENV } from "../../commands/onboard-bot-membership.js";
|
|
|
74
74
|
import { captureHeartbeatIdentityMismatch, identityMismatchMessage, isHeartbeatIdentityMismatch, } from "../heartbeat-identity.js";
|
|
75
75
|
import { clearBotSession, readBotSession, writeBotSession } from "./session.js";
|
|
76
76
|
import { defaultBotStatus, patchBotStatus, readBotStatus } from "./status.js";
|
|
77
|
+
import { CLI_VERSION } from "../../cli-version.js";
|
|
78
|
+
import { CLI_UPDATE_RESTART_EXIT_CODE } from "./cli-update.js";
|
|
77
79
|
export const INBOX_POLL_INTERVAL_MS = 2_000;
|
|
78
80
|
export const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
79
81
|
/** First 429/5xx wait when Retry-After is absent (inbox polls at 2s). */
|
|
@@ -285,7 +287,7 @@ export async function runBot(deps) {
|
|
|
285
287
|
pid,
|
|
286
288
|
...(deps.isProcessAlive ? { isProcessAlive: deps.isProcessAlive } : {}),
|
|
287
289
|
});
|
|
288
|
-
const finish = async (final, code, reason) => {
|
|
290
|
+
const finish = async (final, code, reason, beforeExit) => {
|
|
289
291
|
if (state === "stopped" || state === "failed")
|
|
290
292
|
return;
|
|
291
293
|
state = final;
|
|
@@ -293,9 +295,18 @@ export async function runBot(deps) {
|
|
|
293
295
|
log(final === "failed" ? "error" : "info", `bot ${final}: ${reason}`);
|
|
294
296
|
if (!deps.skipPidLock)
|
|
295
297
|
releasePidLock(dir, lockDeps);
|
|
298
|
+
try {
|
|
299
|
+
beforeExit?.();
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
log("error", `before exit: ${err instanceof Error ? err.message : String(err)}`);
|
|
303
|
+
}
|
|
296
304
|
resolveDone(final);
|
|
297
305
|
exit(code);
|
|
298
306
|
};
|
|
307
|
+
const cliVersion = deps.cliUpdate?.runningVersion ?? CLI_VERSION;
|
|
308
|
+
/** Set once an update restart is due: no new turns, then exit for the supervisor. */
|
|
309
|
+
let cliUpdatePending = null;
|
|
299
310
|
// ── Preflight: creds, pid lock, owner verification ─────────────────────────
|
|
300
311
|
const credsProblem = preflightCreds(dir, config);
|
|
301
312
|
if (credsProblem) {
|
|
@@ -327,8 +338,9 @@ export async function runBot(deps) {
|
|
|
327
338
|
return { stop: async () => { }, done, status: () => null, idle: () => true };
|
|
328
339
|
}
|
|
329
340
|
// A restart is the owner's "try again" after signing in: start clean.
|
|
330
|
-
setStatus({ ...fallback(), pid, state: "starting", canDrainForPromotion: false, runtimeSignIn: null });
|
|
331
|
-
log("info", `starting ${config.name} (${config.runtime}, ${kind} bot${companies.length ? ` of ${companies.join(", ")}` : ""}) as ${config.agentUid} for ${config.ownerUid}`);
|
|
341
|
+
setStatus({ ...fallback(), pid, state: "starting", canDrainForPromotion: false, runtimeSignIn: null, cliVersion });
|
|
342
|
+
log("info", `starting ${config.name} on hq-cli ${cliVersion} (${config.runtime}, ${kind} bot${companies.length ? ` of ${companies.join(", ")}` : ""}) as ${config.agentUid} for ${config.ownerUid}`);
|
|
343
|
+
deps.cliUpdate?.startup(log);
|
|
332
344
|
try {
|
|
333
345
|
const record = await api.getAgent(config.agentUid);
|
|
334
346
|
// Promotion keeps the identity and owner. Ownership alone must not let
|
|
@@ -1476,6 +1488,17 @@ export async function runBot(deps) {
|
|
|
1476
1488
|
while (!stopping && state === "running") {
|
|
1477
1489
|
if (hasPromotionHold(dir))
|
|
1478
1490
|
break;
|
|
1491
|
+
// The install under this process was updated: take nothing new, let the
|
|
1492
|
+
// turns already running finish, then exit so the supervisor loads it.
|
|
1493
|
+
// Queued items were never started or acked; the next process gets them.
|
|
1494
|
+
const update = deps.cliUpdate?.poll(log) ?? null;
|
|
1495
|
+
if (update) {
|
|
1496
|
+
cliUpdatePending = update;
|
|
1497
|
+
stopping = true;
|
|
1498
|
+
const busy = running.size;
|
|
1499
|
+
log("info", `hq-cli updated ${update.from} -> ${update.to}; restarting to load it${busy ? ` after ${busy} running turn${busy === 1 ? "" : "s"} finish${busy === 1 ? "es" : ""}` : ""}`);
|
|
1500
|
+
break;
|
|
1501
|
+
}
|
|
1479
1502
|
// A kickoff held for a sign-in retries on the same timer as messages.
|
|
1480
1503
|
if (busyLanes.size === 0 &&
|
|
1481
1504
|
(!signInIssue || now().getTime() >= nextSignInRetryAt) &&
|
|
@@ -1525,6 +1548,9 @@ export async function runBot(deps) {
|
|
|
1525
1548
|
return readBotStatus(dir);
|
|
1526
1549
|
}
|
|
1527
1550
|
const stop = async (mode = "abort") => {
|
|
1551
|
+
// An owner stop or a promotion while an update restart drains wins: the
|
|
1552
|
+
// bot then exits 0 and stays down instead of being restarted.
|
|
1553
|
+
cliUpdatePending = null;
|
|
1528
1554
|
if (stopping) {
|
|
1529
1555
|
if (mode === "abort")
|
|
1530
1556
|
for (const turn of currentTurns)
|
|
@@ -1552,8 +1578,20 @@ export async function runBot(deps) {
|
|
|
1552
1578
|
void stop("drain"); });
|
|
1553
1579
|
setStatus({ canDrainForPromotion: true });
|
|
1554
1580
|
void Promise.allSettled([heartbeatLoop, inboxLoop]).then(() => {
|
|
1555
|
-
if (state
|
|
1581
|
+
if (state !== "running")
|
|
1582
|
+
return;
|
|
1583
|
+
const update = cliUpdatePending;
|
|
1584
|
+
if (!update) {
|
|
1556
1585
|
void finish("stopped", 0, "loops ended");
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
try {
|
|
1589
|
+
deps.cliUpdate?.recordRestart(update);
|
|
1590
|
+
}
|
|
1591
|
+
catch (err) {
|
|
1592
|
+
log("warn", `could not record the update restart: ${err instanceof Error ? err.message : String(err)}`);
|
|
1593
|
+
}
|
|
1594
|
+
void finish("stopped", CLI_UPDATE_RESTART_EXIT_CODE, `hq-cli updated ${update.from} -> ${update.to}; restarting to load it`, () => deps.onCliUpdateRestart?.(update, log));
|
|
1557
1595
|
});
|
|
1558
1596
|
return { stop, done, status: readStatus, idle: () => pending.size === 0 };
|
|
1559
1597
|
}
|
package/dist/lib/bot/status.d.ts
CHANGED
|
@@ -44,6 +44,8 @@ export interface BotStatusFile {
|
|
|
44
44
|
* reason); cleared by the next turn that works. `null` = known to work.
|
|
45
45
|
*/
|
|
46
46
|
runtimeSignIn?: RuntimeSignInIssue | null;
|
|
47
|
+
/** hq-cli version this process loaded (a bot keeps it until it restarts). */
|
|
48
|
+
cliVersion?: string;
|
|
47
49
|
updatedAt: string;
|
|
48
50
|
}
|
|
49
51
|
export declare function defaultBotStatus(pid: number, runtime: BotRuntimeId, now?: () => Date): BotStatusFile;
|
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as path from "node:path";
|
|
13
|
-
import { resolveCompany } from "../../work-context/company.js";
|
|
13
|
+
import { readMetaCompanySlug, resolveCompany } from "../../work-context/company.js";
|
|
14
|
+
import { companyBindingChanged, reboundSessionProjection, } from "../../work-context/company-rebind.js";
|
|
14
15
|
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../work-context/repo-remote.js";
|
|
15
16
|
import { rebindNeedsProjectOnPrompt } from "../../work-context/prompt-rebind.js";
|
|
16
|
-
import { readSessionState, updateSessionState } from "../../work-context/state.js";
|
|
17
|
+
import { readSessionState, updateSessionState, writeSessionState } from "../../work-context/state.js";
|
|
18
|
+
import { generateUlid } from "./ulid.js";
|
|
17
19
|
import { repairAckedRegisterStates } from "../../work-context/outbox.js";
|
|
18
20
|
import { workContextRoot } from "../../work-context/paths.js";
|
|
19
21
|
import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.js";
|
|
@@ -252,6 +254,23 @@ export async function flushSessionEvents(deps) {
|
|
|
252
254
|
if (deps.signal?.aborted)
|
|
253
255
|
break sessionLoop;
|
|
254
256
|
let state = readSessionState(sessionId, deps.workContextRoot);
|
|
257
|
+
state = applySessionMetaCompanyRebind(sessionId, items, state, deps, now);
|
|
258
|
+
const leaveResult = await postPresenceLeaveIfNeeded({
|
|
259
|
+
sessionId,
|
|
260
|
+
items,
|
|
261
|
+
state,
|
|
262
|
+
deps,
|
|
263
|
+
now,
|
|
264
|
+
maxAttempts,
|
|
265
|
+
sleep,
|
|
266
|
+
random,
|
|
267
|
+
summary,
|
|
268
|
+
markDisposed,
|
|
269
|
+
});
|
|
270
|
+
state = leaveResult.state;
|
|
271
|
+
if (leaveResult.abort)
|
|
272
|
+
continue;
|
|
273
|
+
const remainingItems = leaveResult.items;
|
|
255
274
|
if (state?.companyConfidence === "device_default" &&
|
|
256
275
|
deps.authorizeDeviceDefaultTenant) {
|
|
257
276
|
const allowed = await deps.authorizeDeviceDefaultTenant(state);
|
|
@@ -259,7 +278,7 @@ export async function flushSessionEvents(deps) {
|
|
|
259
278
|
state = readSessionState(sessionId, deps.workContextRoot) ?? state;
|
|
260
279
|
if (!allowed) {
|
|
261
280
|
const heldAt = now().toISOString();
|
|
262
|
-
for (const item of
|
|
281
|
+
for (const item of remainingItems) {
|
|
263
282
|
appendHeldLine(heldEnvelope(item.event, "NEEDS_COMPANY", item.heldAt ?? heldAt), deps.workMeshRoot);
|
|
264
283
|
summary.held += 1;
|
|
265
284
|
markDisposed(item.event);
|
|
@@ -269,33 +288,34 @@ export async function flushSessionEvents(deps) {
|
|
|
269
288
|
}
|
|
270
289
|
// After the company gate (which may restore a cleared company), try to
|
|
271
290
|
// bind a needs_project session to an existing Board before classifying.
|
|
272
|
-
state = rebindUnboundSession(sessionId,
|
|
291
|
+
state = rebindUnboundSession(sessionId, remainingItems, state, deps, nowMs);
|
|
273
292
|
const decision = classifySession(state);
|
|
274
293
|
if (decision.action === "drop") {
|
|
275
294
|
appendDroppedReceipt({
|
|
276
295
|
v: 1,
|
|
277
296
|
sessionId,
|
|
278
|
-
dropped:
|
|
297
|
+
dropped: remainingItems.length,
|
|
279
298
|
reason: "untracked",
|
|
280
299
|
at: now().toISOString(),
|
|
281
300
|
}, deps.workMeshRoot);
|
|
282
|
-
summary.dropped +=
|
|
283
|
-
for (const item of
|
|
301
|
+
summary.dropped += remainingItems.length;
|
|
302
|
+
for (const item of remainingItems)
|
|
284
303
|
markDisposed(item.event);
|
|
285
304
|
continue;
|
|
286
305
|
}
|
|
287
306
|
if (decision.action === "hold") {
|
|
288
307
|
const heldAt = now().toISOString();
|
|
289
|
-
for (const item of
|
|
308
|
+
for (const item of remainingItems) {
|
|
290
309
|
appendHeldLine(heldEnvelope(item.event, decision.heldReason, item.heldAt ?? heldAt), deps.workMeshRoot);
|
|
291
310
|
summary.held += 1;
|
|
292
311
|
markDisposed(item.event);
|
|
293
312
|
}
|
|
294
313
|
continue;
|
|
295
314
|
}
|
|
296
|
-
// Post a bounded portion of this session's backlog
|
|
297
|
-
//
|
|
298
|
-
|
|
315
|
+
// Post a bounded portion of this session's remaining backlog (after any
|
|
316
|
+
// presence-leave). Untouched events stay in pendingRestore and are put
|
|
317
|
+
// back on the spool below.
|
|
318
|
+
const flushItems = remainingItems.slice(0, SESSION_EVENTS_MAX_PER_SESSION_PER_FLUSH);
|
|
299
319
|
const stripped = flushItems.map((item) => stripLocalOnlyFields(item.event));
|
|
300
320
|
for (let i = 0; i < stripped.length; i += SESSION_EVENTS_BATCH_MAX) {
|
|
301
321
|
const chunk = stripped.slice(i, i + SESSION_EVENTS_BATCH_MAX);
|
|
@@ -547,6 +567,135 @@ function eventText(event, key) {
|
|
|
547
567
|
const value = event[key];
|
|
548
568
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
549
569
|
}
|
|
570
|
+
const SESSION_EVENT_HARNESSES = new Set([
|
|
571
|
+
"claude-code",
|
|
572
|
+
"claude-desktop",
|
|
573
|
+
"codex",
|
|
574
|
+
"grok",
|
|
575
|
+
"hq-sessions",
|
|
576
|
+
"agent-box",
|
|
577
|
+
"agents-v2",
|
|
578
|
+
]);
|
|
579
|
+
function applySessionMetaCompanyRebind(sessionId, items, state, deps, now) {
|
|
580
|
+
if (!state)
|
|
581
|
+
return state;
|
|
582
|
+
const hqRoot = items.map((item) => eventText(item.event, "hqRoot")).find(Boolean);
|
|
583
|
+
if (!hqRoot)
|
|
584
|
+
return state;
|
|
585
|
+
const meta = readMetaCompanySlug(sessionId, hqRoot);
|
|
586
|
+
if (!meta)
|
|
587
|
+
return state;
|
|
588
|
+
if (!companyBindingChanged(state, { slug: meta }))
|
|
589
|
+
return state;
|
|
590
|
+
const next = reboundSessionProjection({
|
|
591
|
+
prior: state,
|
|
592
|
+
sessionId,
|
|
593
|
+
nextSlug: meta,
|
|
594
|
+
nowIso: now().toISOString(),
|
|
595
|
+
});
|
|
596
|
+
writeSessionState(next, deps.workContextRoot);
|
|
597
|
+
return readSessionState(sessionId, deps.workContextRoot) ?? next;
|
|
598
|
+
}
|
|
599
|
+
function leaveContextStatus(leave) {
|
|
600
|
+
if (leave.projectId && leave.taskId)
|
|
601
|
+
return "bound";
|
|
602
|
+
if (leave.projectId)
|
|
603
|
+
return "needs_task";
|
|
604
|
+
return "needs_project";
|
|
605
|
+
}
|
|
606
|
+
function synthesizeSessionEnd(sessionId, sample, seq, at) {
|
|
607
|
+
const harnessRaw = eventText(sample, "harness");
|
|
608
|
+
const harness = harnessRaw && SESSION_EVENT_HARNESSES.has(harnessRaw)
|
|
609
|
+
? harnessRaw
|
|
610
|
+
: "claude-code";
|
|
611
|
+
return stripLocalOnlyFields({
|
|
612
|
+
v: 1,
|
|
613
|
+
eventId: generateUlid(),
|
|
614
|
+
kind: "session_end",
|
|
615
|
+
sessionId,
|
|
616
|
+
harness,
|
|
617
|
+
adapterVersion: eventText(sample, "adapterVersion") || "1.0.0",
|
|
618
|
+
at,
|
|
619
|
+
seq,
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
async function postPresenceLeaveIfNeeded(input) {
|
|
623
|
+
const leave = input.state?.presenceLeave;
|
|
624
|
+
if (!leave?.companyUid) {
|
|
625
|
+
return { state: input.state, items: input.items, abort: false };
|
|
626
|
+
}
|
|
627
|
+
const oldSlug = leave.companySlug?.trim().toLowerCase();
|
|
628
|
+
const leaveItems = oldSlug
|
|
629
|
+
? input.items.filter((item) => {
|
|
630
|
+
const slug = eventText(item.event, "companySlug")?.toLowerCase();
|
|
631
|
+
return slug === oldSlug;
|
|
632
|
+
})
|
|
633
|
+
: [];
|
|
634
|
+
const remaining = input.items.filter((item) => !leaveItems.includes(item));
|
|
635
|
+
const sample = leaveItems[0]?.event ?? input.items[0]?.event ?? {};
|
|
636
|
+
const maxSeq = input.items.reduce((max, item) => {
|
|
637
|
+
const seq = item.event.seq;
|
|
638
|
+
return typeof seq === "number" && Number.isFinite(seq) ? Math.max(max, seq) : max;
|
|
639
|
+
}, 0);
|
|
640
|
+
const sessionEnd = synthesizeSessionEnd(input.sessionId, sample, maxSeq + 1, input.now().toISOString());
|
|
641
|
+
const events = [
|
|
642
|
+
...leaveItems.map((item) => stripLocalOnlyFields(item.event)),
|
|
643
|
+
sessionEnd,
|
|
644
|
+
];
|
|
645
|
+
const body = {
|
|
646
|
+
contractVersion: 1,
|
|
647
|
+
companyUid: leave.companyUid,
|
|
648
|
+
sessionId: input.sessionId,
|
|
649
|
+
contextStatus: leaveContextStatus(leave),
|
|
650
|
+
events,
|
|
651
|
+
};
|
|
652
|
+
if (leave.bindingEpisodeId)
|
|
653
|
+
body.bindingEpisodeId = leave.bindingEpisodeId;
|
|
654
|
+
if (leave.projectId)
|
|
655
|
+
body.projectId = leave.projectId;
|
|
656
|
+
if (leave.taskId)
|
|
657
|
+
body.taskId = leave.taskId;
|
|
658
|
+
input.summary.batches += 1;
|
|
659
|
+
const result = await postBatchWithRetries(input.deps.poster, body, {
|
|
660
|
+
maxAttempts: input.maxAttempts,
|
|
661
|
+
sleep: input.sleep,
|
|
662
|
+
random: input.random,
|
|
663
|
+
signal: input.deps.signal,
|
|
664
|
+
});
|
|
665
|
+
if (!result.ok) {
|
|
666
|
+
const at = input.now().toISOString();
|
|
667
|
+
if (!result.retryable) {
|
|
668
|
+
for (const item of input.items) {
|
|
669
|
+
appendDeadLetterLine(deadLetterEnvelope(item.event, `HTTP_${result.status}`, result.status, at), input.deps.workMeshRoot);
|
|
670
|
+
input.summary.deadLettered += 1;
|
|
671
|
+
input.markDisposed(item.event);
|
|
672
|
+
}
|
|
673
|
+
return { state: input.state, items: [], abort: true };
|
|
674
|
+
}
|
|
675
|
+
for (const item of input.items) {
|
|
676
|
+
appendSpoolLine(JSON.stringify(canonicalizeForRestore(item.event)), input.deps.workMeshRoot);
|
|
677
|
+
input.summary.restored += 1;
|
|
678
|
+
input.markDisposed(item.event);
|
|
679
|
+
}
|
|
680
|
+
return { state: input.state, items: [], abort: true };
|
|
681
|
+
}
|
|
682
|
+
accountBatchSuccess({
|
|
683
|
+
accepted: events.length,
|
|
684
|
+
duplicates: 0,
|
|
685
|
+
rejected: [],
|
|
686
|
+
}, [...leaveItems, { event: sessionEnd }], input.deps.workMeshRoot, input.now, input.summary, input.markDisposed);
|
|
687
|
+
const cleared = updateSessionState(input.sessionId, input.deps.workContextRoot, (current) => {
|
|
688
|
+
if (!current)
|
|
689
|
+
return current;
|
|
690
|
+
const { presenceLeave: _leave, ...rest } = current;
|
|
691
|
+
return { ...rest, updatedAt: input.now().toISOString() };
|
|
692
|
+
});
|
|
693
|
+
return {
|
|
694
|
+
state: cleared ?? input.state,
|
|
695
|
+
items: remaining,
|
|
696
|
+
abort: false,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
550
699
|
/**
|
|
551
700
|
* Before a needs_project (or unresolved-with-company) batch is classified,
|
|
552
701
|
* run the prompt-submit bind-best refinement. A bind updates the session
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-047: when the HQ session company changes, drop the old Work Mesh project
|
|
3
|
+
* and remember a presence leave for the previous company.
|
|
4
|
+
*/
|
|
5
|
+
import type { SessionStateFile } from "./state.js";
|
|
6
|
+
export declare const NON_CLOUD_COMPANY_SLUG = "personal";
|
|
7
|
+
export declare function isNonCloudCompanySlug(slug?: string | null): boolean;
|
|
8
|
+
export declare function companyBindingChanged(prior: SessionStateFile | null | undefined, next: {
|
|
9
|
+
slug?: string;
|
|
10
|
+
uid?: string;
|
|
11
|
+
}): boolean;
|
|
12
|
+
export declare function presenceLeaveFromState(prior: SessionStateFile): NonNullable<SessionStateFile["presenceLeave"]> | undefined;
|
|
13
|
+
export declare function reboundSessionProjection(input: {
|
|
14
|
+
prior: SessionStateFile;
|
|
15
|
+
sessionId: string;
|
|
16
|
+
nextSlug?: string;
|
|
17
|
+
nextUid?: string;
|
|
18
|
+
nowIso: string;
|
|
19
|
+
startedAt?: string;
|
|
20
|
+
toolWrites?: number;
|
|
21
|
+
hasTurn?: boolean;
|
|
22
|
+
}): SessionStateFile;
|
|
23
|
+
//# sourceMappingURL=company-rebind.d.ts.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* US-047: when the HQ session company changes, drop the old Work Mesh project
|
|
3
|
+
* and remember a presence leave for the previous company.
|
|
4
|
+
*/
|
|
5
|
+
import { sameCompany } from "./company.js";
|
|
6
|
+
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
7
|
+
export const NON_CLOUD_COMPANY_SLUG = "personal";
|
|
8
|
+
export function isNonCloudCompanySlug(slug) {
|
|
9
|
+
return slug?.trim().toLowerCase() === NON_CLOUD_COMPANY_SLUG;
|
|
10
|
+
}
|
|
11
|
+
export function companyBindingChanged(prior, next) {
|
|
12
|
+
if (!prior)
|
|
13
|
+
return false;
|
|
14
|
+
if (!prior.companySlug && !prior.companyUid)
|
|
15
|
+
return false;
|
|
16
|
+
if (!next.slug && !next.uid)
|
|
17
|
+
return false;
|
|
18
|
+
return !sameCompany({ slug: prior.companySlug, uid: prior.companyUid }, next);
|
|
19
|
+
}
|
|
20
|
+
export function presenceLeaveFromState(prior) {
|
|
21
|
+
const uid = prior.companyUid?.trim();
|
|
22
|
+
if (!uid)
|
|
23
|
+
return undefined;
|
|
24
|
+
return {
|
|
25
|
+
companyUid: uid,
|
|
26
|
+
...(prior.companySlug ? { companySlug: prior.companySlug } : {}),
|
|
27
|
+
...(prior.projectId ? { projectId: prior.projectId } : {}),
|
|
28
|
+
...(prior.taskId ? { taskId: prior.taskId } : {}),
|
|
29
|
+
...(prior.bindingEpisodeId ? { bindingEpisodeId: prior.bindingEpisodeId } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function reboundSessionProjection(input) {
|
|
33
|
+
const nonCloud = isNonCloudCompanySlug(input.nextSlug) || !input.nextUid;
|
|
34
|
+
const leave = presenceLeaveFromState(input.prior);
|
|
35
|
+
const out = {
|
|
36
|
+
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
37
|
+
sessionId: input.sessionId,
|
|
38
|
+
contextStatus: nonCloud ? "needs_company" : "needs_project",
|
|
39
|
+
updatedAt: input.nowIso,
|
|
40
|
+
};
|
|
41
|
+
if (input.nextSlug)
|
|
42
|
+
out.companySlug = input.nextSlug;
|
|
43
|
+
if (!nonCloud && input.nextUid)
|
|
44
|
+
out.companyUid = input.nextUid;
|
|
45
|
+
const startedAt = input.startedAt ?? input.prior.startedAt;
|
|
46
|
+
if (startedAt)
|
|
47
|
+
out.startedAt = startedAt;
|
|
48
|
+
if (typeof input.toolWrites === "number")
|
|
49
|
+
out.toolWrites = input.toolWrites;
|
|
50
|
+
else if (typeof input.prior.toolWrites === "number") {
|
|
51
|
+
out.toolWrites = input.prior.toolWrites;
|
|
52
|
+
}
|
|
53
|
+
if (input.hasTurn || input.prior.hasTurn)
|
|
54
|
+
out.hasTurn = true;
|
|
55
|
+
if (leave)
|
|
56
|
+
out.presenceLeave = leave;
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=company-rebind.js.map
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Order:
|
|
5
5
|
* 1. trusted explicit (HQ_SPAWN_COMPANY env or observation.trustedContext/explicit)
|
|
6
|
-
* 2. existing authoritative session scope
|
|
7
|
-
*
|
|
6
|
+
* 2. existing authoritative session scope — unless session meta.yaml
|
|
7
|
+
* names a different company (hq-session.sh rebind, US-047)
|
|
8
|
+
* 3. session meta.yaml company_slug (/startwork / hq-session.sh)
|
|
8
9
|
* 4. agent-box identity file (HQ_AGENT_IDENTITY_FILE → companyUid; source
|
|
9
10
|
* trusted_explicit — never "default company mode"; below HQ_SPAWN_COMPANY,
|
|
10
11
|
* above device default)
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Order:
|
|
5
5
|
* 1. trusted explicit (HQ_SPAWN_COMPANY env or observation.trustedContext/explicit)
|
|
6
|
-
* 2. existing authoritative session scope
|
|
7
|
-
*
|
|
6
|
+
* 2. existing authoritative session scope — unless session meta.yaml
|
|
7
|
+
* names a different company (hq-session.sh rebind, US-047)
|
|
8
|
+
* 3. session meta.yaml company_slug (/startwork / hq-session.sh)
|
|
8
9
|
* 4. agent-box identity file (HQ_AGENT_IDENTITY_FILE → companyUid; source
|
|
9
10
|
* trusted_explicit — never "default company mode"; below HQ_SPAWN_COMPANY,
|
|
10
11
|
* above device default)
|
|
@@ -254,12 +255,23 @@ export function resolveCompany(input) {
|
|
|
254
255
|
? input.existingState
|
|
255
256
|
: readSessionState(input.sessionId, input.root);
|
|
256
257
|
const prior = authoritativeCompanyFromState(state);
|
|
258
|
+
// 3. session meta.yaml company_slug — read before existing_scope so an
|
|
259
|
+
// operator rebind via hq-session.sh can replace a stale Work Mesh company.
|
|
260
|
+
const metaSlug = input.metaCompanySlug !== undefined
|
|
261
|
+
? normalizeSlug(input.metaCompanySlug)
|
|
262
|
+
: readMetaCompanySlug(input.sessionId, input.hqRoot);
|
|
257
263
|
if (prior) {
|
|
258
264
|
const scoped = {
|
|
259
265
|
slug: prior.slug,
|
|
260
266
|
uid: prior.uid,
|
|
261
267
|
source: "existing_scope",
|
|
262
268
|
};
|
|
269
|
+
if (metaSlug && !sameCompany(scoped, { slug: metaSlug })) {
|
|
270
|
+
return {
|
|
271
|
+
status: "resolved",
|
|
272
|
+
company: { slug: metaSlug, source: "session_meta" },
|
|
273
|
+
};
|
|
274
|
+
}
|
|
263
275
|
// Deterministic evidence from another company never silently replaces a
|
|
264
276
|
// scoped session, even before it has a project binding. Preserve the
|
|
265
277
|
// original scope and surface a conflict before tenant I/O.
|
|
@@ -288,10 +300,6 @@ export function resolveCompany(input) {
|
|
|
288
300
|
}
|
|
289
301
|
return { status: "resolved", company: scoped };
|
|
290
302
|
}
|
|
291
|
-
// 3. session meta.yaml company_slug
|
|
292
|
-
const metaSlug = input.metaCompanySlug !== undefined
|
|
293
|
-
? normalizeSlug(input.metaCompanySlug)
|
|
294
|
-
: readMetaCompanySlug(input.sessionId, input.hqRoot);
|
|
295
303
|
if (metaSlug) {
|
|
296
304
|
return {
|
|
297
305
|
status: "resolved",
|
|
@@ -12,6 +12,7 @@ export * from "./config.js";
|
|
|
12
12
|
export * from "./state.js";
|
|
13
13
|
export * from "./outbox.js";
|
|
14
14
|
export * from "./company.js";
|
|
15
|
+
export { companyBindingChanged, isNonCloudCompanySlug, presenceLeaveFromState, reboundSessionProjection, } from "./company-rebind.js";
|
|
15
16
|
export * from "./project.js";
|
|
16
17
|
export { decisionFromCandidates, digestOrganizePayload, formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, stableDecisionId, stableOrganizeOperationId, submitOrganizeDecision, } from "./organize.js";
|
|
17
18
|
export type { CandidatesResponse, FetchCandidatesFn, OrganizeCandidate, OrganizeListResult, OrganizeReceipt, OrganizeSubmitDecision, OrganizeSubmitResult, PostOrganizeFn, } from "./organize.js";
|
|
@@ -12,6 +12,7 @@ export * from "./config.js";
|
|
|
12
12
|
export * from "./state.js";
|
|
13
13
|
export * from "./outbox.js";
|
|
14
14
|
export * from "./company.js";
|
|
15
|
+
export { companyBindingChanged, isNonCloudCompanySlug, presenceLeaveFromState, reboundSessionProjection, } from "./company-rebind.js";
|
|
15
16
|
export * from "./project.js";
|
|
16
17
|
export { decisionFromCandidates, digestOrganizePayload, formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, stableDecisionId, stableOrganizeOperationId, submitOrganizeDecision, } from "./organize.js";
|
|
17
18
|
export { digestMigratePayload, formatMigrateConfirmation, stableMigrateOperationId, submitSessionMigration, } from "./migrate.js";
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
6
|
import { InvalidSessionIdentityError, normalizeSessionIdentity, } from "../mesh/live/session-identity.js";
|
|
7
7
|
import { companyCorrectionPath, companySlugFromCwd, resolveCompany, } from "./company.js";
|
|
8
|
+
import { companyBindingChanged, isNonCloudCompanySlug, presenceLeaveFromState, reboundSessionProjection, } from "./company-rebind.js";
|
|
8
9
|
import { invalidateDefaultCompanyMemberships, } from "./config.js";
|
|
9
10
|
import { WORK_CONTEXT_CONTRACT_VERSION, normalizeTaskId, } from "./contract.js";
|
|
10
11
|
import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOriginError, NotTrackingError, } from "./errors.js";
|
|
@@ -354,6 +355,43 @@ export async function reconcileObservation(obs, deps) {
|
|
|
354
355
|
// A device default is weak, cached evidence: validate it authoritatively on
|
|
355
356
|
// every tenant-I/O path so a membership revocation takes effect immediately.
|
|
356
357
|
let resolvedCompany = company.company;
|
|
358
|
+
if (isNonCloudCompanySlug(resolvedCompany.slug)) {
|
|
359
|
+
const projection = prior
|
|
360
|
+
? reboundSessionProjection({
|
|
361
|
+
prior,
|
|
362
|
+
sessionId,
|
|
363
|
+
nextSlug: resolvedCompany.slug,
|
|
364
|
+
nowIso,
|
|
365
|
+
startedAt,
|
|
366
|
+
toolWrites,
|
|
367
|
+
hasTurn,
|
|
368
|
+
})
|
|
369
|
+
: {
|
|
370
|
+
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
371
|
+
sessionId,
|
|
372
|
+
contextStatus: "needs_company",
|
|
373
|
+
companySlug: resolvedCompany.slug,
|
|
374
|
+
updatedAt: nowIso,
|
|
375
|
+
startedAt,
|
|
376
|
+
...(typeof toolWrites === "number" ? { toolWrites } : {}),
|
|
377
|
+
...(hasTurn ? { hasTurn: true } : {}),
|
|
378
|
+
};
|
|
379
|
+
const statePath = writeSessionState(mergeLocalSessionFields({ ...projection, ...transcriptFields }, prior, { preserveBindingEpisode: false, preserveCompanyBinding: false }), deps.root);
|
|
380
|
+
diagnostics.push(`company rebind to ${resolvedCompany.slug}; cleared project; held (non-cloud)`);
|
|
381
|
+
return {
|
|
382
|
+
result: resultOf({
|
|
383
|
+
kind: "needs_company",
|
|
384
|
+
classification: "needs_company",
|
|
385
|
+
delivery: "clean",
|
|
386
|
+
sessionId,
|
|
387
|
+
clientOperationId: obs.clientOperationId,
|
|
388
|
+
companySlug: resolvedCompany.slug,
|
|
389
|
+
}),
|
|
390
|
+
exitCode: EXIT_OK,
|
|
391
|
+
diagnostics: [...diagnostics, "needs_company: non-cloud session company; no tenant I/O"],
|
|
392
|
+
statePath,
|
|
393
|
+
};
|
|
394
|
+
}
|
|
357
395
|
const deviceDefaultRequiresValidation = resolvedCompany.source === "device_default";
|
|
358
396
|
if (deviceDefaultRequiresValidation && !deps.validateMembership) {
|
|
359
397
|
return {
|
|
@@ -385,6 +423,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
385
423
|
invalidateDefaultCompanyMemberships({ root: deps.root, now: nowFn });
|
|
386
424
|
}
|
|
387
425
|
diagnostics.push(`membership denied for slug=${resolvedCompany.slug}; treating as needs_company`);
|
|
426
|
+
const deniedLeave = prior ? presenceLeaveFromState(prior) : undefined;
|
|
388
427
|
const statePath = writeSessionState(mergeLocalSessionFields({
|
|
389
428
|
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
390
429
|
sessionId,
|
|
@@ -395,7 +434,10 @@ export async function reconcileObservation(obs, deps) {
|
|
|
395
434
|
...(typeof toolWrites === "number" ? { toolWrites } : {}),
|
|
396
435
|
...(hasTurn ? { hasTurn: true } : {}),
|
|
397
436
|
...transcriptFields,
|
|
398
|
-
|
|
437
|
+
...(deniedLeave ? { presenceLeave: deniedLeave } : {}),
|
|
438
|
+
}, prior, companyBindingChanged(prior, resolvedCompany)
|
|
439
|
+
? { preserveBindingEpisode: false, preserveCompanyBinding: false }
|
|
440
|
+
: {}), deps.root);
|
|
399
441
|
return {
|
|
400
442
|
result: resultOf({
|
|
401
443
|
kind: "needs_company",
|
|
@@ -431,13 +473,23 @@ export async function reconcileObservation(obs, deps) {
|
|
|
431
473
|
}
|
|
432
474
|
}
|
|
433
475
|
// Company resolved — resolve project/task (US-007B order).
|
|
476
|
+
const companyRebind = companyBindingChanged(prior, resolvedCompany);
|
|
477
|
+
const projectPrior = companyRebind && prior
|
|
478
|
+
? {
|
|
479
|
+
...prior,
|
|
480
|
+
projectId: undefined,
|
|
481
|
+
taskId: undefined,
|
|
482
|
+
bindingEpisodeId: undefined,
|
|
483
|
+
contextStatus: "needs_project",
|
|
484
|
+
}
|
|
485
|
+
: prior;
|
|
434
486
|
let projectResolved = resolveProjectTask({
|
|
435
487
|
sessionId,
|
|
436
488
|
env,
|
|
437
489
|
trusted,
|
|
438
490
|
cwd,
|
|
439
491
|
hqRoot,
|
|
440
|
-
existingState:
|
|
492
|
+
existingState: projectPrior,
|
|
441
493
|
});
|
|
442
494
|
// Deterministic cwd projects belong to the cwd company. A higher-precedence
|
|
443
495
|
// identity UID must not adopt a project from an unrelated/unverified slug.
|
|
@@ -537,7 +589,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
537
589
|
}
|
|
538
590
|
}
|
|
539
591
|
const projectHint = !projectId
|
|
540
|
-
? (prior?.projectHint || projectResolved?.projectId)
|
|
592
|
+
? (companyRebind ? projectResolved?.projectId : (prior?.projectHint || projectResolved?.projectId))
|
|
541
593
|
: undefined;
|
|
542
594
|
const bindResultFields = {
|
|
543
595
|
...(projectBindReason ? { projectBindReason } : {}),
|
|
@@ -634,6 +686,10 @@ export async function reconcileObservation(obs, deps) {
|
|
|
634
686
|
// Do not let a weak later observation erase a server-acknowledged company
|
|
635
687
|
// binding. Keep the result unresolved so the normal candidate threshold can
|
|
636
688
|
// still advance the session, while preserving the durable emitter projection.
|
|
689
|
+
if (companyRebind && !projectId && resolvedCompany.uid) {
|
|
690
|
+
registerClassification = "needs_project";
|
|
691
|
+
diagnostics.push(`company rebind to ${resolvedCompany.slug ?? resolvedCompany.uid}; cleared project`);
|
|
692
|
+
}
|
|
637
693
|
const stateClassification = registerClassification === "unresolved" &&
|
|
638
694
|
prior?.contextStatus === "needs_project" &&
|
|
639
695
|
prior.companyUid === resolvedCompany.uid &&
|
|
@@ -707,9 +763,12 @@ export async function reconcileObservation(obs, deps) {
|
|
|
707
763
|
};
|
|
708
764
|
}
|
|
709
765
|
let outboxOp;
|
|
710
|
-
const companyConfidence =
|
|
766
|
+
const companyConfidence = !companyRebind &&
|
|
767
|
+
company.status === "resolved" &&
|
|
768
|
+
company.company.source === "device_default"
|
|
711
769
|
? "device_default"
|
|
712
770
|
: undefined;
|
|
771
|
+
const presenceLeave = companyRebind && prior ? presenceLeaveFromState(prior) : undefined;
|
|
713
772
|
try {
|
|
714
773
|
outboxOp = enqueueOutbox({
|
|
715
774
|
clientOperationId: obs.clientOperationId,
|
|
@@ -762,9 +821,12 @@ export async function reconcileObservation(obs, deps) {
|
|
|
762
821
|
...(hasTurn ? { hasTurn: true } : {}),
|
|
763
822
|
...(decision ? { decision } : {}),
|
|
764
823
|
...(companyConfidence ? { companyConfidence } : {}),
|
|
824
|
+
...(presenceLeave ? { presenceLeave } : {}),
|
|
765
825
|
...bindStateFields,
|
|
766
826
|
...transcriptFields,
|
|
767
|
-
}, prior
|
|
827
|
+
}, prior, companyRebind
|
|
828
|
+
? { preserveBindingEpisode: false, preserveCompanyBinding: false }
|
|
829
|
+
: {});
|
|
768
830
|
statePath = writeSessionState(stateWrite, deps.root);
|
|
769
831
|
}
|
|
770
832
|
catch (err) {
|
|
@@ -871,6 +933,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
871
933
|
sessionId,
|
|
872
934
|
clientOperationId: obs.clientOperationId,
|
|
873
935
|
companyUid: resolvedCompany.uid,
|
|
936
|
+
companySlug: resolvedCompany.slug,
|
|
874
937
|
projectId,
|
|
875
938
|
taskId,
|
|
876
939
|
bindingEpisodeId: delivered.receiptId,
|
|
@@ -106,6 +106,17 @@ export interface SessionStateFile {
|
|
|
106
106
|
projectGate?: "choose" | "missing_board";
|
|
107
107
|
/** Slug the session was trying to bind. Re-checked on the next prompt. */
|
|
108
108
|
projectHint?: string;
|
|
109
|
+
/**
|
|
110
|
+
* LOCAL-ONLY. Pending session_end against a company this session just left
|
|
111
|
+
* (US-047). Flush posts it to that companyUid, then clears the field.
|
|
112
|
+
*/
|
|
113
|
+
presenceLeave?: {
|
|
114
|
+
companyUid: string;
|
|
115
|
+
companySlug?: string;
|
|
116
|
+
projectId?: string;
|
|
117
|
+
taskId?: string;
|
|
118
|
+
bindingEpisodeId?: string;
|
|
119
|
+
};
|
|
109
120
|
updatedAt: string;
|
|
110
121
|
}
|
|
111
122
|
/**
|
|
@@ -33,6 +33,7 @@ export const SESSION_STATE_ALLOWLIST = new Set([
|
|
|
33
33
|
"projectCandidates",
|
|
34
34
|
"projectGate",
|
|
35
35
|
"projectHint",
|
|
36
|
+
"presenceLeave",
|
|
36
37
|
"updatedAt",
|
|
37
38
|
]);
|
|
38
39
|
const SESSION_STATE_LOCK_TIMEOUT_MS = 5_000;
|
|
@@ -240,6 +241,8 @@ export function projectSessionState(state) {
|
|
|
240
241
|
out.projectGate = state.projectGate;
|
|
241
242
|
if (state.projectHint && !state.projectId)
|
|
242
243
|
out.projectHint = state.projectHint;
|
|
244
|
+
if (state.presenceLeave?.companyUid)
|
|
245
|
+
out.presenceLeave = state.presenceLeave;
|
|
243
246
|
return out;
|
|
244
247
|
}
|
|
245
248
|
/**
|
|
@@ -298,9 +301,15 @@ export function mergeLocalSessionFields(next, prior, options = {}) {
|
|
|
298
301
|
if (!out.companyConfidence && prior.companyConfidence && sameCompanyBinding) {
|
|
299
302
|
out.companyConfidence = prior.companyConfidence;
|
|
300
303
|
}
|
|
301
|
-
if (!out.projectId &&
|
|
304
|
+
if (!out.projectId &&
|
|
305
|
+
!out.projectHint &&
|
|
306
|
+
prior.projectHint &&
|
|
307
|
+
sameCompanyBinding) {
|
|
302
308
|
out.projectHint = prior.projectHint;
|
|
303
309
|
}
|
|
310
|
+
if (!out.presenceLeave && prior.presenceLeave) {
|
|
311
|
+
out.presenceLeave = prior.presenceLeave;
|
|
312
|
+
}
|
|
304
313
|
if (out.projectId)
|
|
305
314
|
out.projectHint = undefined;
|
|
306
315
|
const sameBinding = prior.companyUid === out.companyUid &&
|