@indigoai-us/hq-cli 5.108.21 → 5.108.23
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 +43 -0
- package/dist/commands/mesh.js +114 -2
- package/dist/lib/doctor/checks/runtime-probe.js +1 -1
- package/dist/lib/mesh/api.js +14 -1
- package/dist/lib/mesh/client.js +2 -0
- package/dist/lib/mesh/live/backfill-held.d.ts +81 -0
- package/dist/lib/mesh/live/backfill-held.js +131 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +2 -1
- package/dist/lib/mesh/live/daemon/transcript-watch.js +18 -26
- package/dist/lib/mesh/live/format-spool-line.d.ts +1 -1
- package/dist/lib/mesh/live/session-event.schema.json +2 -1
- package/dist/lib/mesh/live/validate-session-event.d.ts +1 -1
- package/dist/lib/mesh/live/validate-session-event.js +1 -1
- package/dist/lib/work-context/company.d.ts +3 -1
- package/dist/lib/work-context/company.js +6 -2
- package/dist/lib/work-context/outbox.d.ts +23 -1
- package/dist/lib/work-context/outbox.js +48 -1
- package/dist/lib/work-context/reconcile.js +11 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.108.23] — 2026-09-08
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `hq mesh context requeue` flips AUTH_DENIED-quarantined work-context outbox
|
|
10
|
+
operations (registrations, reconciles) back to queued so a box whose
|
|
11
|
+
registrations were denied before the ID-token fix recovers without a manual
|
|
12
|
+
reset (gap 10, #539).
|
|
13
|
+
- `hq mesh context backfill-held` reconciles each unattributed held session
|
|
14
|
+
through the shared company resolver so the daemon's held retry can drain the
|
|
15
|
+
ended-session backlog that would otherwise never re-attribute. Opt-in and
|
|
16
|
+
per-box, with `--dry-run`/`--offline`/`--limit`/`--company`/`--json` (gap 2b,
|
|
17
|
+
#540).
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Work-context commands (`hq mesh context reconcile`/register) on agent boxes now
|
|
22
|
+
authenticate with the machine ID token, matching the presence daemon, instead
|
|
23
|
+
of the access token the server cannot attribute to the agent entity (which
|
|
24
|
+
answered 403 NO_PERSON_ENTITY and quarantined registrations as AUTH_DENIED).
|
|
25
|
+
requireToken() mints the machine ID token whenever the box is a machine
|
|
26
|
+
identity (gap 10, #539).
|
|
27
|
+
- Work Mesh Live session events that carry the `agents-v2` runtime harness are
|
|
28
|
+
accepted by the shared session-event schema instead of being rejected as
|
|
29
|
+
SCHEMA_INVALID and dead-lettered; both copies of the schema stay byte-identical
|
|
30
|
+
(gap 9, #541).
|
|
31
|
+
|
|
32
|
+
### Testing
|
|
33
|
+
|
|
34
|
+
- The held-TTL flush tests (`HELD_OVERFLOW`, the 150k mid-flush-throw) inject a
|
|
35
|
+
deterministic clock instead of the real wall clock, so they no longer flip red
|
|
36
|
+
the moment the hardcoded `heldAt` fixtures cross the 7-day held TTL (#542).
|
|
37
|
+
|
|
38
|
+
## [5.108.22] — 2026-09-07
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- Work Mesh registrations now retain the runtime harness in the durable outbox
|
|
43
|
+
and send it to the server, including a compatible fallback for older queued
|
|
44
|
+
operations. Fleet sessions discover their company from the standard agent
|
|
45
|
+
identity file without requiring a launcher environment variable, and transcript
|
|
46
|
+
registration uses the shared company precedence and conflict rules.
|
|
47
|
+
|
|
5
48
|
## [5.108.21] — 2026-09-07
|
|
6
49
|
|
|
7
50
|
### Fixed
|
package/dist/commands/mesh.js
CHANGED
|
@@ -16,6 +16,7 @@ import { DefaultCompanyLockedError, DefaultCompanyUnavailableError, } from "../l
|
|
|
16
16
|
import { isValidSessionId } from "../lib/mesh/live/session-identity.js";
|
|
17
17
|
import { CLI_KIND_TO_SCHEMA, EnqueueValidationError, enqueueSessionEvent, } from "../lib/mesh/live/index.js";
|
|
18
18
|
import { flushSessionEvents } from "../lib/mesh/live/flush.js";
|
|
19
|
+
import { backfillHeldSessions } from "../lib/mesh/live/backfill-held.js";
|
|
19
20
|
import { createSessionEventsPoster, resolveVaultApiBase, } from "../lib/mesh/live/session-events-client.js";
|
|
20
21
|
import { workMeshRoot } from "../lib/mesh/live/paths.js";
|
|
21
22
|
import { buildInstallPaths, collectDaemonDoctor, daemonServiceStatus, detectPlatform, formatDaemonDoctor, installDaemonService, readDaemonState, runMeshDaemon, uninstallDaemonService, daemonDir, } from "../lib/mesh/live/daemon/index.js";
|
|
@@ -24,6 +25,7 @@ import { formatMigrateConfirmation, submitSessionMigration, } from "../lib/work-
|
|
|
24
25
|
import { formatOrganizeList, prepareOrganizeDecision, settleOrganizeAskWithoutBind, submitOrganizeDecision, } from "../lib/work-context/organize.js";
|
|
25
26
|
import { readSessionState } from "../lib/work-context/state.js";
|
|
26
27
|
import { loadObservationFromFile, markUntracked, parseObservationJson, reconcileObservation, } from "../lib/work-context/reconcile.js";
|
|
28
|
+
import { requeueQuarantinedOutbox } from "../lib/work-context/outbox.js";
|
|
27
29
|
export function formatCheckLines(threads, company, projectId) {
|
|
28
30
|
if (threads.length === 0) {
|
|
29
31
|
return ["Work mesh: no active project threads found."];
|
|
@@ -178,6 +180,75 @@ async function runContextReconcile(opts) {
|
|
|
178
180
|
}
|
|
179
181
|
process.exitCode = outcome.exitCode;
|
|
180
182
|
}
|
|
183
|
+
async function runContextBackfillHeld(opts) {
|
|
184
|
+
const root = workContextHomeRoot();
|
|
185
|
+
const meshRoot = workMeshRoot(undefined, process.env);
|
|
186
|
+
const dryRun = Boolean(opts.dryRun);
|
|
187
|
+
const parsedLimit = opts.limit ? Number.parseInt(opts.limit, 10) : 0;
|
|
188
|
+
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 0;
|
|
189
|
+
const companyHint = opts.company?.trim() || undefined;
|
|
190
|
+
// Build the reconcile network seam exactly like `hq mesh context reconcile`.
|
|
191
|
+
// Not needed in --dry-run (reconcile is never invoked then). Explicit types
|
|
192
|
+
// because these are captured in the reconcile closure below (no control-flow
|
|
193
|
+
// narrowing, unlike the inline reconcile handler).
|
|
194
|
+
let deliver;
|
|
195
|
+
let fetchCandidates;
|
|
196
|
+
let validateMembership;
|
|
197
|
+
let offline = Boolean(opts.offline);
|
|
198
|
+
if (!dryRun && !offline) {
|
|
199
|
+
try {
|
|
200
|
+
const token = await requireToken();
|
|
201
|
+
deliver = createWorkSessionDeliverer({ token });
|
|
202
|
+
fetchCandidates = createCandidatesFetcher({ token });
|
|
203
|
+
validateMembership = async (candidate) => {
|
|
204
|
+
const membership = await resolveActiveMembershipCompany(token, candidate);
|
|
205
|
+
if (!membership)
|
|
206
|
+
return false;
|
|
207
|
+
return { uid: membership.companyUid };
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
offline = true;
|
|
212
|
+
if (!opts.json) {
|
|
213
|
+
console.error(chalk.yellow("No Cognito session; reconciling offline (outbox only)."));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const result = await backfillHeldSessions({
|
|
218
|
+
workMeshRoot: meshRoot,
|
|
219
|
+
workContextRoot: root,
|
|
220
|
+
dryRun,
|
|
221
|
+
limit,
|
|
222
|
+
reconcile: (obs) => reconcileObservation(
|
|
223
|
+
// Pass --company as a non-forcing hint (remoteOwnerSlug feeds
|
|
224
|
+
// deterministic resolution BELOW the identity-file / device default,
|
|
225
|
+
// so the shared resolver still wins, as in normal reconcile).
|
|
226
|
+
companyHint ? { ...obs, remoteOwnerSlug: companyHint } : obs, {
|
|
227
|
+
root,
|
|
228
|
+
env: process.env,
|
|
229
|
+
deliver,
|
|
230
|
+
fetchCandidates,
|
|
231
|
+
offline,
|
|
232
|
+
validateMembership,
|
|
233
|
+
}),
|
|
234
|
+
});
|
|
235
|
+
if (opts.json) {
|
|
236
|
+
console.log(JSON.stringify({ ok: true, action: "backfill-held", ...result }, null, 2));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const verb = dryRun ? "would reconcile" : "reconciled";
|
|
240
|
+
console.log(`Backfill held: scanned ${result.scanned} session(s)` +
|
|
241
|
+
`${limit > 0 ? ` (considered ${result.considered})` : ""}; ` +
|
|
242
|
+
`${result.alreadyAttributed} already attributed, ` +
|
|
243
|
+
`${verb} ${result.reconciled}, ` +
|
|
244
|
+
`${result.unresolved} unresolved, ` +
|
|
245
|
+
`${result.errors} error(s).` +
|
|
246
|
+
(dryRun ? " (dry-run: nothing written)" : ""));
|
|
247
|
+
if (!dryRun && result.reconciled > 0) {
|
|
248
|
+
console.log("Reconciled session state files were rewritten with a resolved company; " +
|
|
249
|
+
"the mesh daemon's held retry will re-attribute and post those events.");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
181
252
|
async function runContextDefaultGet(opts) {
|
|
182
253
|
const root = workContextHomeRoot();
|
|
183
254
|
const cfg = readDeviceConfig({ root });
|
|
@@ -393,6 +464,30 @@ async function runContextDefaultClear(opts) {
|
|
|
393
464
|
}
|
|
394
465
|
console.log("Default company cleared.");
|
|
395
466
|
}
|
|
467
|
+
async function runContextRequeue(opts) {
|
|
468
|
+
const root = workContextHomeRoot();
|
|
469
|
+
// Default: only AUTH_DENIED (the gap 10 regression). --all clears the code
|
|
470
|
+
// filter entirely; --error-code targets one specific lastErrorCode.
|
|
471
|
+
const errorCode = opts.all
|
|
472
|
+
? null
|
|
473
|
+
: opts.errorCode?.trim()
|
|
474
|
+
? opts.errorCode.trim()
|
|
475
|
+
: undefined;
|
|
476
|
+
const requeued = requeueQuarantinedOutbox(root, { errorCode });
|
|
477
|
+
if (opts.json) {
|
|
478
|
+
console.log(JSON.stringify({
|
|
479
|
+
ok: true,
|
|
480
|
+
requeued: requeued.length,
|
|
481
|
+
errorCode: errorCode === undefined ? "AUTH_DENIED" : errorCode,
|
|
482
|
+
operationIds: requeued.map((op) => op.operationId),
|
|
483
|
+
}, null, 2));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const scope = errorCode === null
|
|
487
|
+
? "all quarantine reasons"
|
|
488
|
+
: `lastErrorCode=${errorCode === undefined ? "AUTH_DENIED" : errorCode}`;
|
|
489
|
+
console.log(`Requeued ${requeued.length} quarantined outbox operation${requeued.length === 1 ? "" : "s"} (${scope}). The mesh daemon will retry them on its next replay.`);
|
|
490
|
+
}
|
|
396
491
|
async function runContextUntracked(sessionId, opts) {
|
|
397
492
|
if (!isValidSessionId(sessionId)) {
|
|
398
493
|
fail(`Invalid session id: ${sessionId}`);
|
|
@@ -524,6 +619,7 @@ const HARNESSES = new Set([
|
|
|
524
619
|
"grok",
|
|
525
620
|
"hq-sessions",
|
|
526
621
|
"agent-box",
|
|
622
|
+
"agents-v2",
|
|
527
623
|
]);
|
|
528
624
|
function workMeshHomeRoot() {
|
|
529
625
|
return workMeshRoot(os.homedir(), process.env);
|
|
@@ -537,7 +633,7 @@ async function runSessionEnqueue(cliKind, opts) {
|
|
|
537
633
|
fail(`Unknown session verb: ${cliKind}`);
|
|
538
634
|
const harness = (opts.harness?.trim() || "");
|
|
539
635
|
if (!HARNESSES.has(harness)) {
|
|
540
|
-
fail("--harness is required (claude-code|codex|grok|hq-sessions|agent-box)");
|
|
636
|
+
fail("--harness is required (claude-code|codex|grok|hq-sessions|agent-box|agents-v2)");
|
|
541
637
|
}
|
|
542
638
|
const adapterVersion = opts.adapterVersion?.trim();
|
|
543
639
|
if (!adapterVersion)
|
|
@@ -621,7 +717,7 @@ function addSessionEnqueueFlags(cmd) {
|
|
|
621
717
|
return cmd
|
|
622
718
|
.option("--enqueue", "Append one line to ~/.hq/work-mesh/spool.jsonl (no network)")
|
|
623
719
|
.option("--session-id <id>", "Session id (else HQ_SESSION_ID / harness env)")
|
|
624
|
-
.option("--harness <name>", "claude-code|codex|grok|hq-sessions|agent-box")
|
|
720
|
+
.option("--harness <name>", "claude-code|codex|grok|hq-sessions|agent-box|agents-v2")
|
|
625
721
|
.option("--adapter-version <ver>", "Hook / adapter version")
|
|
626
722
|
.option("--runtime-version <ver>", "Host runtime version")
|
|
627
723
|
.option("--seq <n>", "Monotonic per-session sequence (>= 1)")
|
|
@@ -712,6 +808,15 @@ export function registerMeshCommand(program) {
|
|
|
712
808
|
.option("--machine", "Write exactly one ContextResult JSON line to stdout")
|
|
713
809
|
.option("--offline", "Skip network; leave register operations queued in the outbox")
|
|
714
810
|
.action((opts) => wrap(() => runContextReconcile(opts))());
|
|
811
|
+
context
|
|
812
|
+
.command("backfill-held")
|
|
813
|
+
.description("Reconcile ENDED sessions whose held events lack a company so the daemon re-attributes the backlog (explicit, opt-in; no fleet fan-out)")
|
|
814
|
+
.option("--dry-run", "Report what would be reconciled; write nothing, no network")
|
|
815
|
+
.option("--offline", "Reconcile outbox-only (no candidates/register network)")
|
|
816
|
+
.option("--limit <n>", "Cap sessions processed (default 0 = no cap)")
|
|
817
|
+
.option("--company <slug>", "Optional non-forcing company hint (resolver default still wins)")
|
|
818
|
+
.option("--json", "Print machine-readable JSON")
|
|
819
|
+
.action((opts) => wrap(() => runContextBackfillHeld(opts))());
|
|
715
820
|
context
|
|
716
821
|
.command("organize")
|
|
717
822
|
.description("List or submit one-time project/task decisions (US-007B / US-005B)")
|
|
@@ -744,6 +849,13 @@ export function registerMeshCommand(program) {
|
|
|
744
849
|
.option("--json", "Print machine-readable JSON")
|
|
745
850
|
.option("--machine", "Compact JSON on stdout")
|
|
746
851
|
.action((opts) => wrap(() => runContextCorrect(opts))());
|
|
852
|
+
context
|
|
853
|
+
.command("requeue")
|
|
854
|
+
.description("Requeue quarantined outbox operations (default: only AUTH_DENIED — work-mesh-live gap 10 recovery)")
|
|
855
|
+
.option("--all", "Requeue every quarantined op regardless of lastErrorCode")
|
|
856
|
+
.option("--error-code <code>", "Requeue quarantined ops with this lastErrorCode")
|
|
857
|
+
.option("--json", "Print machine-readable JSON")
|
|
858
|
+
.action((opts) => wrap(() => runContextRequeue(opts))());
|
|
747
859
|
const def = context
|
|
748
860
|
.command("default")
|
|
749
861
|
.description("Manage the device-local default company preference");
|
|
@@ -300,7 +300,7 @@ export function checkRuntimeProbe(context) {
|
|
|
300
300
|
status: "PASS",
|
|
301
301
|
checkId,
|
|
302
302
|
target,
|
|
303
|
-
message: `Host platform is
|
|
303
|
+
message: `Host platform is the Agents v2 runtime: the on-box adapter wrote the ` +
|
|
304
304
|
`policy-trigger ledger through the same .claude hooks, so hook dispatch was ` +
|
|
305
305
|
`observed this session — the ledger has an entry${scope}.`,
|
|
306
306
|
},
|
package/dist/lib/mesh/api.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* machine cache apps already read. Does not spawn the pack helper and does
|
|
6
6
|
* not start MQTT listen.
|
|
7
7
|
*/
|
|
8
|
-
import { ensureCognitoToken } from "../../utils/cognito-session.js";
|
|
8
|
+
import { ensureCognitoToken, isMachineIdentity } from "../../utils/cognito-session.js";
|
|
9
9
|
import { peekIdToken } from "../../utils/id-token.js";
|
|
10
10
|
import { getCompanyUid, vaultApiFetch } from "../../utils/vault-api.js";
|
|
11
11
|
import { meshCacheRoot, removeSessionFallbackFiles, writeMeshCacheFile, } from "./cache.js";
|
|
@@ -119,6 +119,19 @@ export async function resolveActiveMembershipCompany(token, slug) {
|
|
|
119
119
|
return null;
|
|
120
120
|
}
|
|
121
121
|
export async function requireToken() {
|
|
122
|
+
// Agent/machine boxes must authenticate the way the mesh daemon does:
|
|
123
|
+
// an agent's identity claims (custom:entityType=agent, custom:entityUid)
|
|
124
|
+
// ride the ID token ONLY; a person-cache ACCESS token on the box carries
|
|
125
|
+
// no entity claims and the server answers 403 NO_PERSON_ENTITY, which the
|
|
126
|
+
// outbox then quarantines as AUTH_DENIED (work-mesh-live gap 10). Force
|
|
127
|
+
// the machine mint whenever machine creds are readable so we always send
|
|
128
|
+
// the agent ID token, matching src/lib/mesh/live/daemon/run.ts. Per the
|
|
129
|
+
// hard policy indigo-agent-caller-resolution-id-token, agent claims ride
|
|
130
|
+
// the ID token only; the server is correct to require it. Humans keep the
|
|
131
|
+
// person access token (unchanged).
|
|
132
|
+
if (isMachineIdentity()) {
|
|
133
|
+
return ensureCognitoToken({ tokenSource: "machine" });
|
|
134
|
+
}
|
|
122
135
|
return ensureCognitoToken();
|
|
123
136
|
}
|
|
124
137
|
function asRecord(value) {
|
package/dist/lib/mesh/client.js
CHANGED
|
@@ -24,6 +24,8 @@ export function createWorkSessionDeliverer(opts) {
|
|
|
24
24
|
contractVersion: 1,
|
|
25
25
|
companyUid,
|
|
26
26
|
sessionId: op.sessionId,
|
|
27
|
+
// Pre-upgrade outbox records have no harness; do not invent a provider.
|
|
28
|
+
harness: op.harness?.trim() || "unknown",
|
|
27
29
|
clientOperationId: op.clientOperationId,
|
|
28
30
|
operationId: op.operationId,
|
|
29
31
|
digest: op.digest,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backfill held session events by reconciling their ENDED sessions (gap 2b).
|
|
3
|
+
*
|
|
4
|
+
* Background: hq-cli 5.108.22 fixed company resolution for sessions reconciled
|
|
5
|
+
* from now on, but the daemon's held retry (see flush.ts `classifySession` →
|
|
6
|
+
* NEEDS_COMPANY) decides purely from the per-session work-context state file,
|
|
7
|
+
* and only a reconcile rewrites that file. Held events belong to sessions that
|
|
8
|
+
* already ENDED, so nothing reconciles them again — the held backlog sits held
|
|
9
|
+
* forever.
|
|
10
|
+
*
|
|
11
|
+
* This module re-runs `reconcileObservation` once per distinct held session
|
|
12
|
+
* that still lacks a companyUid, which rewrites its state file with the resolved
|
|
13
|
+
* company (via the identity-file default resolver shipped in 5.108.22). The
|
|
14
|
+
* daemon's next held retry then re-attributes and posts those events naturally.
|
|
15
|
+
*
|
|
16
|
+
* Constraints (owner directive):
|
|
17
|
+
* - Explicit, opt-in only. Never automatic; never wired into a hook or daemon.
|
|
18
|
+
* - Idempotent: sessions that already carry a companyUid are skipped.
|
|
19
|
+
* - Never deletes held events; the daemon posts them on the next retry.
|
|
20
|
+
* - Purely local to the box it runs on; no fleet fan-out.
|
|
21
|
+
*/
|
|
22
|
+
import type { ReconcileObservation, ReconcileOutcome } from "../../work-context/reconcile.js";
|
|
23
|
+
import { type SessionStateFile } from "../../work-context/state.js";
|
|
24
|
+
/** One distinct session discovered in held.jsonl, with its harness. */
|
|
25
|
+
export interface HeldSessionRef {
|
|
26
|
+
sessionId: string;
|
|
27
|
+
harness?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface BackfillHeldResult {
|
|
30
|
+
/** Distinct sessions found in held.jsonl (before --limit). */
|
|
31
|
+
scanned: number;
|
|
32
|
+
/** Distinct sessions considered this run (after --limit). */
|
|
33
|
+
considered: number;
|
|
34
|
+
/** Skipped because state already has a companyUid. */
|
|
35
|
+
alreadyAttributed: number;
|
|
36
|
+
/** Reconcile invoked and a company was resolved (would-reconcile in dry-run). */
|
|
37
|
+
reconciled: number;
|
|
38
|
+
/** Reconcile ran but resolver found no company. */
|
|
39
|
+
unresolved: number;
|
|
40
|
+
/** Sessions that errored during reconcile. */
|
|
41
|
+
errors: number;
|
|
42
|
+
dryRun: boolean;
|
|
43
|
+
}
|
|
44
|
+
export interface BackfillHeldDeps {
|
|
45
|
+
/** Work-mesh root that holds held.jsonl. */
|
|
46
|
+
workMeshRoot: string;
|
|
47
|
+
/** Work-context root that holds sessions/<sid>.json. */
|
|
48
|
+
workContextRoot: string;
|
|
49
|
+
/** Report only; write nothing, call no network, invoke no reconcile. */
|
|
50
|
+
dryRun?: boolean;
|
|
51
|
+
/** Cap distinct sessions processed. 0 (default) = no cap. */
|
|
52
|
+
limit?: number;
|
|
53
|
+
/** Contract version stamped on the synthesized observation. */
|
|
54
|
+
contractVersion?: number;
|
|
55
|
+
/** Fresh clientOperationId per reconcile. Default crypto.randomUUID. */
|
|
56
|
+
newOperationId?: () => string;
|
|
57
|
+
/** State reader seam. Default readSessionState. */
|
|
58
|
+
readState?: (sessionId: string, root: string) => SessionStateFile | null;
|
|
59
|
+
/**
|
|
60
|
+
* Reconcile one observation. NOT called in dry-run. The CLI wrapper wires
|
|
61
|
+
* this to reconcileObservation with the real deliver/fetchCandidates/
|
|
62
|
+
* validateMembership seam (or offline fallback), exactly mirroring the
|
|
63
|
+
* `hq mesh context reconcile` handler.
|
|
64
|
+
*/
|
|
65
|
+
reconcile: (obs: ReconcileObservation) => Promise<ReconcileOutcome>;
|
|
66
|
+
/** Optional progress logger. */
|
|
67
|
+
log?: (message: string) => void;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Read held.jsonl and return distinct sessions (first occurrence wins),
|
|
71
|
+
* carrying the harness from whichever held event we saw first for that session.
|
|
72
|
+
* Lines that fail to parse or carry no sessionId are ignored (the daemon owns
|
|
73
|
+
* their disposition; this backfill never mutates or deletes held lines).
|
|
74
|
+
*/
|
|
75
|
+
export declare function readHeldSessions(workMeshRoot: string): HeldSessionRef[];
|
|
76
|
+
/**
|
|
77
|
+
* Reconcile ended sessions whose held events lack a company. Idempotent:
|
|
78
|
+
* re-running skips sessions that already carry a companyUid.
|
|
79
|
+
*/
|
|
80
|
+
export declare function backfillHeldSessions(deps: BackfillHeldDeps): Promise<BackfillHeldResult>;
|
|
81
|
+
//# sourceMappingURL=backfill-held.d.ts.map
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backfill held session events by reconciling their ENDED sessions (gap 2b).
|
|
3
|
+
*
|
|
4
|
+
* Background: hq-cli 5.108.22 fixed company resolution for sessions reconciled
|
|
5
|
+
* from now on, but the daemon's held retry (see flush.ts `classifySession` →
|
|
6
|
+
* NEEDS_COMPANY) decides purely from the per-session work-context state file,
|
|
7
|
+
* and only a reconcile rewrites that file. Held events belong to sessions that
|
|
8
|
+
* already ENDED, so nothing reconciles them again — the held backlog sits held
|
|
9
|
+
* forever.
|
|
10
|
+
*
|
|
11
|
+
* This module re-runs `reconcileObservation` once per distinct held session
|
|
12
|
+
* that still lacks a companyUid, which rewrites its state file with the resolved
|
|
13
|
+
* company (via the identity-file default resolver shipped in 5.108.22). The
|
|
14
|
+
* daemon's next held retry then re-attributes and posts those events naturally.
|
|
15
|
+
*
|
|
16
|
+
* Constraints (owner directive):
|
|
17
|
+
* - Explicit, opt-in only. Never automatic; never wired into a hook or daemon.
|
|
18
|
+
* - Idempotent: sessions that already carry a companyUid are skipped.
|
|
19
|
+
* - Never deletes held events; the daemon posts them on the next retry.
|
|
20
|
+
* - Purely local to the box it runs on; no fleet fan-out.
|
|
21
|
+
*/
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import { readSessionState } from "../../work-context/state.js";
|
|
24
|
+
import { WORK_CONTEXT_CONTRACT_VERSION } from "../../work-context/contract.js";
|
|
25
|
+
import { workMeshHeldPath } from "./paths.js";
|
|
26
|
+
/**
|
|
27
|
+
* Read held.jsonl and return distinct sessions (first occurrence wins),
|
|
28
|
+
* carrying the harness from whichever held event we saw first for that session.
|
|
29
|
+
* Lines that fail to parse or carry no sessionId are ignored (the daemon owns
|
|
30
|
+
* their disposition; this backfill never mutates or deletes held lines).
|
|
31
|
+
*/
|
|
32
|
+
export function readHeldSessions(workMeshRoot) {
|
|
33
|
+
const heldPath = workMeshHeldPath(workMeshRoot);
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = fs.readFileSync(heldPath, "utf8");
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const seen = new Map();
|
|
42
|
+
for (const line of raw.split("\n")) {
|
|
43
|
+
const trimmed = line.trim();
|
|
44
|
+
if (!trimmed)
|
|
45
|
+
continue;
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(trimmed);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
54
|
+
continue;
|
|
55
|
+
const obj = parsed;
|
|
56
|
+
// Peel the held envelope { event, heldReason, heldAt } if present.
|
|
57
|
+
const event = obj.event &&
|
|
58
|
+
typeof obj.event === "object" &&
|
|
59
|
+
!Array.isArray(obj.event) &&
|
|
60
|
+
typeof obj.heldReason === "string"
|
|
61
|
+
? obj.event
|
|
62
|
+
: obj;
|
|
63
|
+
const sessionId = typeof event.sessionId === "string" && event.sessionId.trim()
|
|
64
|
+
? event.sessionId.trim()
|
|
65
|
+
: null;
|
|
66
|
+
if (!sessionId || seen.has(sessionId))
|
|
67
|
+
continue;
|
|
68
|
+
const harness = typeof event.harness === "string" && event.harness.trim()
|
|
69
|
+
? event.harness.trim()
|
|
70
|
+
: undefined;
|
|
71
|
+
seen.set(sessionId, { sessionId, harness });
|
|
72
|
+
}
|
|
73
|
+
return [...seen.values()];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Reconcile ended sessions whose held events lack a company. Idempotent:
|
|
77
|
+
* re-running skips sessions that already carry a companyUid.
|
|
78
|
+
*/
|
|
79
|
+
export async function backfillHeldSessions(deps) {
|
|
80
|
+
const dryRun = Boolean(deps.dryRun);
|
|
81
|
+
const limit = deps.limit && deps.limit > 0 ? deps.limit : 0;
|
|
82
|
+
const contractVersion = deps.contractVersion ?? WORK_CONTEXT_CONTRACT_VERSION;
|
|
83
|
+
const readState = deps.readState ?? readSessionState;
|
|
84
|
+
const newOperationId = deps.newOperationId ?? (() => globalThis.crypto.randomUUID());
|
|
85
|
+
const sessions = readHeldSessions(deps.workMeshRoot);
|
|
86
|
+
const considered = limit > 0 ? sessions.slice(0, limit) : sessions;
|
|
87
|
+
const result = {
|
|
88
|
+
scanned: sessions.length,
|
|
89
|
+
considered: considered.length,
|
|
90
|
+
alreadyAttributed: 0,
|
|
91
|
+
reconciled: 0,
|
|
92
|
+
unresolved: 0,
|
|
93
|
+
errors: 0,
|
|
94
|
+
dryRun,
|
|
95
|
+
};
|
|
96
|
+
for (const { sessionId, harness } of considered) {
|
|
97
|
+
const state = readState(sessionId, deps.workContextRoot);
|
|
98
|
+
if (state?.companyUid) {
|
|
99
|
+
result.alreadyAttributed += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (dryRun) {
|
|
103
|
+
// Would reconcile: no state read/write beyond the skip check above.
|
|
104
|
+
result.reconciled += 1;
|
|
105
|
+
deps.log?.(`would reconcile ${sessionId}${harness ? ` (${harness})` : ""}`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const obs = {
|
|
109
|
+
contractVersion,
|
|
110
|
+
identity: harness ? { sessionId, harness } : { sessionId },
|
|
111
|
+
clientOperationId: newOperationId(),
|
|
112
|
+
};
|
|
113
|
+
try {
|
|
114
|
+
const outcome = await deps.reconcile(obs);
|
|
115
|
+
if (outcome.result.companyUid) {
|
|
116
|
+
result.reconciled += 1;
|
|
117
|
+
deps.log?.(`reconciled ${sessionId} → ${outcome.result.companyUid}`);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
result.unresolved += 1;
|
|
121
|
+
deps.log?.(`unresolved ${sessionId} (no company)`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
result.errors += 1;
|
|
126
|
+
deps.log?.(`error ${sessionId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
//# sourceMappingURL=backfill-held.js.map
|
|
@@ -219,10 +219,11 @@ export declare function isTranscriptHookCovered(sessionId: string, opts: {
|
|
|
219
219
|
}): boolean;
|
|
220
220
|
/**
|
|
221
221
|
* Resolve local registration for a transcript session. Never asks, never
|
|
222
|
-
* creates a project.
|
|
222
|
+
* creates a project. Shares reconcile's company precedence and conflict rules.
|
|
223
223
|
*/
|
|
224
224
|
export declare function resolveTranscriptRegistration(input: {
|
|
225
225
|
sessionId: string;
|
|
226
|
+
env?: NodeJS.ProcessEnv;
|
|
226
227
|
cwd?: string;
|
|
227
228
|
hqRoot?: string;
|
|
228
229
|
workContextRoot: string;
|
|
@@ -29,8 +29,7 @@ import * as fs from "node:fs";
|
|
|
29
29
|
import * as os from "node:os";
|
|
30
30
|
import * as path from "node:path";
|
|
31
31
|
import { CLI_VERSION } from "../../../../cli-version.js";
|
|
32
|
-
import { companySlugFromCwd, projectIdFromCwd,
|
|
33
|
-
import { getDefaultCompany } from "../../../work-context/config.js";
|
|
32
|
+
import { companySlugFromCwd, projectIdFromCwd, resolveCompany, } from "../../../work-context/company.js";
|
|
34
33
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "../../../work-context/contract.js";
|
|
35
34
|
import { reconcileObservation, } from "../../../work-context/reconcile.js";
|
|
36
35
|
import { deriveRemoteOwnerSlug } from "../../../work-context/repo-remote.js";
|
|
@@ -441,7 +440,7 @@ export function isTranscriptHookCovered(sessionId, opts) {
|
|
|
441
440
|
}
|
|
442
441
|
/**
|
|
443
442
|
* Resolve local registration for a transcript session. Never asks, never
|
|
444
|
-
* creates a project.
|
|
443
|
+
* creates a project. Shares reconcile's company precedence and conflict rules.
|
|
445
444
|
*/
|
|
446
445
|
export function resolveTranscriptRegistration(input) {
|
|
447
446
|
const now = input.now ?? (() => new Date());
|
|
@@ -449,33 +448,26 @@ export function resolveTranscriptRegistration(input) {
|
|
|
449
448
|
const remoteOwnerSlug = input.cwd
|
|
450
449
|
? deriveRemoteOwnerSlug({ cwd: input.cwd, hqRoot: input.hqRoot })
|
|
451
450
|
: null;
|
|
452
|
-
const
|
|
451
|
+
const resolution = resolveCompany({
|
|
452
|
+
root: input.workContextRoot,
|
|
453
|
+
sessionId: input.sessionId,
|
|
454
|
+
env: input.env,
|
|
453
455
|
cwd: input.cwd,
|
|
454
456
|
hqRoot: input.hqRoot,
|
|
455
457
|
remoteOwnerSlug,
|
|
456
458
|
});
|
|
457
|
-
const
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
let contextStatus;
|
|
470
|
-
if (!companySlug) {
|
|
471
|
-
contextStatus = "needs_company";
|
|
472
|
-
}
|
|
473
|
-
else if (projectId) {
|
|
474
|
-
contextStatus = "needs_task";
|
|
475
|
-
}
|
|
476
|
-
else {
|
|
477
|
-
contextStatus = "unresolved";
|
|
478
|
-
}
|
|
459
|
+
const company = resolution.status === "resolved" ? resolution.company : undefined;
|
|
460
|
+
const companySlug = company?.slug;
|
|
461
|
+
const companyUid = company?.uid;
|
|
462
|
+
// A cwd project belongs to its cwd company, never to a higher-precedence
|
|
463
|
+
// identity or explicit scope naming another (or not-yet-mapped) company.
|
|
464
|
+
const cwdCompany = companySlugFromCwd(input.cwd, input.hqRoot);
|
|
465
|
+
const projectId = companySlug && cwdCompany?.toLowerCase() === companySlug.toLowerCase()
|
|
466
|
+
? projectIdFromCwd(input.cwd, input.hqRoot)
|
|
467
|
+
: undefined;
|
|
468
|
+
const contextStatus = resolution.status === "company_conflict"
|
|
469
|
+
? "company_conflict"
|
|
470
|
+
: !company ? "needs_company" : projectId ? "needs_task" : "unresolved";
|
|
479
471
|
const state = {
|
|
480
472
|
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
481
473
|
sessionId: input.sessionId,
|
|
@@ -7,7 +7,7 @@ export type LocalOnlyField = (typeof LOCAL_ONLY_FIELDS)[number];
|
|
|
7
7
|
/** Network + local key order matching work-mesh-enqueue.sh. */
|
|
8
8
|
export declare const SPOOL_KEY_ORDER: readonly ["v", "eventId", "kind", "sessionId", "harness", "adapterVersion", "runtimeVersion", "source", "at", "seq", "taskId", "status", "reason", "summary", "cwd", "hqRoot", "companySlug", "project", "task", "toolWrites"];
|
|
9
9
|
export type SessionEventKind = "session_start" | "turn_start" | "turn_end" | "session_end" | "task_status" | "blocked" | "note";
|
|
10
|
-
export type SessionEventHarness = "claude-code" | "claude-desktop" | "codex" | "grok" | "hq-sessions" | "agent-box";
|
|
10
|
+
export type SessionEventHarness = "claude-code" | "claude-desktop" | "codex" | "grok" | "hq-sessions" | "agent-box" | "agents-v2";
|
|
11
11
|
export type SessionEventSource = "hooks" | "transcript";
|
|
12
12
|
export interface SpoolEventInput {
|
|
13
13
|
v?: 1;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { ValidateFunction } from "ajv";
|
|
9
9
|
export declare const SESSION_EVENT_SCHEMA_PATH: string;
|
|
10
10
|
/** Expected SHA-256 of the canonical schema bytes (hq-pro + hq-cli must match). */
|
|
11
|
-
export declare const SESSION_EVENT_SCHEMA_SHA256 = "
|
|
11
|
+
export declare const SESSION_EVENT_SCHEMA_SHA256 = "4284bcc3718b8c9008c26156c1f79ae5cfd70fdd248beb2b1c776988f109e415";
|
|
12
12
|
export declare const PROHIBITED_CONTENT_CLASSES: readonly ["prompts", "model_output", "transcripts", "message_bodies", "tokens", "credentials"];
|
|
13
13
|
export type ProhibitedContentClass = (typeof PROHIBITED_CONTENT_CLASSES)[number];
|
|
14
14
|
/** Fixture field name → prohibited class. */
|
|
@@ -13,7 +13,7 @@ import Ajv2020 from "ajv/dist/2020.js";
|
|
|
13
13
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
export const SESSION_EVENT_SCHEMA_PATH = join(HERE, "session-event.schema.json");
|
|
15
15
|
/** Expected SHA-256 of the canonical schema bytes (hq-pro + hq-cli must match). */
|
|
16
|
-
export const SESSION_EVENT_SCHEMA_SHA256 = "
|
|
16
|
+
export const SESSION_EVENT_SCHEMA_SHA256 = "4284bcc3718b8c9008c26156c1f79ae5cfd70fdd248beb2b1c776988f109e415";
|
|
17
17
|
export const PROHIBITED_CONTENT_CLASSES = [
|
|
18
18
|
"prompts",
|
|
19
19
|
"model_output",
|
|
@@ -58,9 +58,11 @@ export interface CompanyResolveInput {
|
|
|
58
58
|
}
|
|
59
59
|
/** Env naming the on-box identity.json (fleet agent boxes). */
|
|
60
60
|
export declare const HQ_AGENT_IDENTITY_FILE_ENV = "HQ_AGENT_IDENTITY_FILE";
|
|
61
|
+
export declare const DEFAULT_AGENT_IDENTITY_FILE = "/var/lib/hq-agent/identity.json";
|
|
61
62
|
/**
|
|
62
63
|
* Read `companyUid` from the agent identity file when
|
|
63
|
-
* `HQ_AGENT_IDENTITY_FILE` is set
|
|
64
|
+
* `HQ_AGENT_IDENTITY_FILE` is set, or the conventional fleet path otherwise.
|
|
65
|
+
* Missing/unreadable/malformed → undefined
|
|
64
66
|
* (never throws; never logs file contents).
|
|
65
67
|
*/
|
|
66
68
|
export declare function readAgentIdentityCompanyUid(env?: NodeJS.ProcessEnv): string | undefined;
|
|
@@ -26,6 +26,7 @@ export function companyCorrectionPath(sessionId) {
|
|
|
26
26
|
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
27
27
|
/** Env naming the on-box identity.json (fleet agent boxes). */
|
|
28
28
|
export const HQ_AGENT_IDENTITY_FILE_ENV = "HQ_AGENT_IDENTITY_FILE";
|
|
29
|
+
export const DEFAULT_AGENT_IDENTITY_FILE = "/var/lib/hq-agent/identity.json";
|
|
29
30
|
function normalizeSlug(value) {
|
|
30
31
|
if (!value)
|
|
31
32
|
return undefined;
|
|
@@ -36,11 +37,14 @@ function normalizeSlug(value) {
|
|
|
36
37
|
}
|
|
37
38
|
/**
|
|
38
39
|
* Read `companyUid` from the agent identity file when
|
|
39
|
-
* `HQ_AGENT_IDENTITY_FILE` is set
|
|
40
|
+
* `HQ_AGENT_IDENTITY_FILE` is set, or the conventional fleet path otherwise.
|
|
41
|
+
* Missing/unreadable/malformed → undefined
|
|
40
42
|
* (never throws; never logs file contents).
|
|
41
43
|
*/
|
|
42
44
|
export function readAgentIdentityCompanyUid(env = process.env) {
|
|
43
|
-
|
|
45
|
+
// An explicit override (including empty/missing paths) never falls back to
|
|
46
|
+
// another identity. This prevents accidental attribution to a different box.
|
|
47
|
+
const file = (env[HQ_AGENT_IDENTITY_FILE_ENV] ?? DEFAULT_AGENT_IDENTITY_FILE).trim();
|
|
44
48
|
if (!file)
|
|
45
49
|
return undefined;
|
|
46
50
|
try {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import type { DeliveryState } from "./contract.js";
|
|
6
6
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
7
7
|
/** Fields allowed on a durable outbox operation (privacy allowlist). */
|
|
8
|
-
export declare const OUTBOX_ALLOWLIST: readonly ["contractVersion", "operationId", "clientOperationId", "sessionId", "companyUid", "companySlug", "projectId", "taskId", "kind", "digest", "delivery", "createdAt", "updatedAt", "attemptCount", "lastErrorCode", "receiptId", "destinationCompanyUid", "destinationCompanySlug", "nextAttemptAt"];
|
|
8
|
+
export declare const OUTBOX_ALLOWLIST: readonly ["contractVersion", "operationId", "clientOperationId", "sessionId", "harness", "companyUid", "companySlug", "projectId", "taskId", "kind", "digest", "delivery", "createdAt", "updatedAt", "attemptCount", "lastErrorCode", "receiptId", "destinationCompanyUid", "destinationCompanySlug", "nextAttemptAt"];
|
|
9
9
|
/** Base delay for outbox retry backoff (attempt 1 → 30s). */
|
|
10
10
|
export declare const OUTBOX_RETRY_BASE_MS = 30000;
|
|
11
11
|
/** Cap for outbox retry backoff (6 hours). */
|
|
@@ -20,6 +20,7 @@ export interface OutboxOperation {
|
|
|
20
20
|
operationId: string;
|
|
21
21
|
clientOperationId: string;
|
|
22
22
|
sessionId: string;
|
|
23
|
+
harness?: string;
|
|
23
24
|
companyUid?: string;
|
|
24
25
|
companySlug?: string;
|
|
25
26
|
projectId?: string;
|
|
@@ -41,6 +42,7 @@ export interface OutboxOperation {
|
|
|
41
42
|
export interface OutboxEnqueueInput {
|
|
42
43
|
clientOperationId: string;
|
|
43
44
|
sessionId: string;
|
|
45
|
+
harness?: string;
|
|
44
46
|
companyUid?: string;
|
|
45
47
|
companySlug?: string;
|
|
46
48
|
projectId?: string;
|
|
@@ -54,6 +56,7 @@ export declare function clearOutboxListCache(): void;
|
|
|
54
56
|
export declare function stableOperationId(clientOperationId: string, sessionId: string): string;
|
|
55
57
|
export declare function digestOperation(parts: {
|
|
56
58
|
sessionId: string;
|
|
59
|
+
harness?: string;
|
|
57
60
|
clientOperationId: string;
|
|
58
61
|
companyUid?: string;
|
|
59
62
|
companySlug?: string;
|
|
@@ -75,6 +78,25 @@ export declare function updateOutboxOperation(op: OutboxOperation, root: string)
|
|
|
75
78
|
export declare function markOutboxAcked(operationId: string, root: string, receiptId: string, now?: () => Date): OutboxOperation | null;
|
|
76
79
|
export declare function markOutboxQueued(operationId: string, root: string, errorCode: string, now?: () => Date, random?: () => number): OutboxOperation | null;
|
|
77
80
|
export declare function markOutboxQuarantined(operationId: string, root: string, errorCode: string, now?: () => Date): OutboxOperation | null;
|
|
81
|
+
/**
|
|
82
|
+
* Requeue quarantined outbox operations so the daemon's replay picks them up
|
|
83
|
+
* again. Quarantine is otherwise terminal: after the work-mesh-live gap 10 fix
|
|
84
|
+
* (agent boxes now send the ID token, see src/lib/mesh/api.ts requireToken),
|
|
85
|
+
* ops previously quarantined as AUTH_DENIED — 403 NO_PERSON_ENTITY caused by
|
|
86
|
+
* sending the person ACCESS token — will not retry on their own. This flips
|
|
87
|
+
* them back to `queued`, resets attemptCount, and clears nextAttemptAt so they
|
|
88
|
+
* are due immediately.
|
|
89
|
+
*
|
|
90
|
+
* `opts.errorCode` selects which quarantined ops to requeue by lastErrorCode:
|
|
91
|
+
* - omitted → defaults to "AUTH_DENIED" (a bare call only recovers the gap 10
|
|
92
|
+
* regression, never e.g. VALIDATION_FAILED ops that are genuinely bad).
|
|
93
|
+
* - a string → only ops whose lastErrorCode matches.
|
|
94
|
+
* - `null` → every quarantined op regardless of code.
|
|
95
|
+
*/
|
|
96
|
+
export declare function requeueQuarantinedOutbox(root: string, opts?: {
|
|
97
|
+
errorCode?: string | null;
|
|
98
|
+
now?: () => Date;
|
|
99
|
+
}): OutboxOperation[];
|
|
78
100
|
export type OutboxListReadFile = (filePath: string) => string;
|
|
79
101
|
export declare function listOutboxOperations(root: string, deps?: {
|
|
80
102
|
readFile?: OutboxListReadFile;
|
|
@@ -15,6 +15,7 @@ export const OUTBOX_ALLOWLIST = [
|
|
|
15
15
|
"operationId",
|
|
16
16
|
"clientOperationId",
|
|
17
17
|
"sessionId",
|
|
18
|
+
"harness",
|
|
18
19
|
"companyUid",
|
|
19
20
|
"companySlug",
|
|
20
21
|
"projectId",
|
|
@@ -59,6 +60,7 @@ export function digestOperation(parts) {
|
|
|
59
60
|
const canonical = JSON.stringify({
|
|
60
61
|
v: 1,
|
|
61
62
|
sessionId: parts.sessionId,
|
|
63
|
+
...(parts.harness ? { harness: parts.harness } : {}),
|
|
62
64
|
clientOperationId: parts.clientOperationId,
|
|
63
65
|
companyUid: parts.companyUid ?? null,
|
|
64
66
|
companySlug: parts.companySlug ?? null,
|
|
@@ -83,6 +85,8 @@ function projectOutbox(op) {
|
|
|
83
85
|
updatedAt: op.updatedAt,
|
|
84
86
|
attemptCount: op.attemptCount,
|
|
85
87
|
};
|
|
88
|
+
if (op.harness)
|
|
89
|
+
out.harness = op.harness;
|
|
86
90
|
if (op.companyUid)
|
|
87
91
|
out.companyUid = op.companyUid;
|
|
88
92
|
if (op.companySlug)
|
|
@@ -132,6 +136,7 @@ export function enqueueOutbox(input, root) {
|
|
|
132
136
|
}
|
|
133
137
|
const digest = digestOperation({
|
|
134
138
|
sessionId: input.sessionId,
|
|
139
|
+
harness: input.harness,
|
|
135
140
|
clientOperationId: input.clientOperationId,
|
|
136
141
|
companyUid: input.companyUid,
|
|
137
142
|
companySlug: input.companySlug,
|
|
@@ -144,7 +149,14 @@ export function enqueueOutbox(input, root) {
|
|
|
144
149
|
const now = (input.now ?? (() => new Date()))().toISOString();
|
|
145
150
|
const existing = readOutboxOperation(operationId, root);
|
|
146
151
|
if (existing) {
|
|
147
|
-
|
|
152
|
+
// A pre-upgrade operation has no harness in its digest. Preserve its
|
|
153
|
+
// original receipt identity when the same observation is enqueued again.
|
|
154
|
+
const legacyReplay = !existing.harness && input.harness && existing.digest === digestOperation({
|
|
155
|
+
...input,
|
|
156
|
+
harness: undefined,
|
|
157
|
+
kind,
|
|
158
|
+
});
|
|
159
|
+
if (existing.digest !== digest && !legacyReplay) {
|
|
148
160
|
throw new NotTrackingError(`Idempotency conflict for ${operationId}`, "IdempotencyConflictError");
|
|
149
161
|
}
|
|
150
162
|
return existing;
|
|
@@ -154,6 +166,7 @@ export function enqueueOutbox(input, root) {
|
|
|
154
166
|
operationId,
|
|
155
167
|
clientOperationId: input.clientOperationId,
|
|
156
168
|
sessionId: input.sessionId,
|
|
169
|
+
harness: input.harness,
|
|
157
170
|
companyUid: input.companyUid,
|
|
158
171
|
companySlug: input.companySlug,
|
|
159
172
|
projectId: input.projectId,
|
|
@@ -270,6 +283,40 @@ export function markOutboxQuarantined(operationId, root, errorCode, now = () =>
|
|
|
270
283
|
updateOutboxOperation(op, root);
|
|
271
284
|
return op;
|
|
272
285
|
}
|
|
286
|
+
/**
|
|
287
|
+
* Requeue quarantined outbox operations so the daemon's replay picks them up
|
|
288
|
+
* again. Quarantine is otherwise terminal: after the work-mesh-live gap 10 fix
|
|
289
|
+
* (agent boxes now send the ID token, see src/lib/mesh/api.ts requireToken),
|
|
290
|
+
* ops previously quarantined as AUTH_DENIED — 403 NO_PERSON_ENTITY caused by
|
|
291
|
+
* sending the person ACCESS token — will not retry on their own. This flips
|
|
292
|
+
* them back to `queued`, resets attemptCount, and clears nextAttemptAt so they
|
|
293
|
+
* are due immediately.
|
|
294
|
+
*
|
|
295
|
+
* `opts.errorCode` selects which quarantined ops to requeue by lastErrorCode:
|
|
296
|
+
* - omitted → defaults to "AUTH_DENIED" (a bare call only recovers the gap 10
|
|
297
|
+
* regression, never e.g. VALIDATION_FAILED ops that are genuinely bad).
|
|
298
|
+
* - a string → only ops whose lastErrorCode matches.
|
|
299
|
+
* - `null` → every quarantined op regardless of code.
|
|
300
|
+
*/
|
|
301
|
+
export function requeueQuarantinedOutbox(root, opts = {}) {
|
|
302
|
+
const errorCode = opts.errorCode === undefined ? "AUTH_DENIED" : opts.errorCode;
|
|
303
|
+
const now = opts.now ?? (() => new Date());
|
|
304
|
+
const requeued = [];
|
|
305
|
+
for (const op of listOutboxOperations(root)) {
|
|
306
|
+
if (op.delivery !== "quarantined")
|
|
307
|
+
continue;
|
|
308
|
+
if (errorCode !== null && op.lastErrorCode !== errorCode)
|
|
309
|
+
continue;
|
|
310
|
+
op.delivery = "queued";
|
|
311
|
+
op.attemptCount = 0;
|
|
312
|
+
op.lastErrorCode = undefined;
|
|
313
|
+
op.nextAttemptAt = undefined;
|
|
314
|
+
op.updatedAt = now().toISOString();
|
|
315
|
+
updateOutboxOperation(op, root);
|
|
316
|
+
requeued.push(op);
|
|
317
|
+
}
|
|
318
|
+
return requeued;
|
|
319
|
+
}
|
|
273
320
|
export function listOutboxOperations(root, deps = {}) {
|
|
274
321
|
const readFile = deps.readFile ?? ((filePath) => fs.readFileSync(filePath, "utf8"));
|
|
275
322
|
const dir = workContextOutboxDir(root);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
6
|
import { InvalidSessionIdentityError, normalizeSessionIdentity, } from "../mesh/live/session-identity.js";
|
|
7
|
-
import { companyCorrectionPath, resolveCompany, } from "./company.js";
|
|
7
|
+
import { companyCorrectionPath, companySlugFromCwd, resolveCompany, } from "./company.js";
|
|
8
8
|
import { WORK_CONTEXT_CONTRACT_VERSION, normalizeTaskId, } from "./contract.js";
|
|
9
9
|
import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOriginError, NotTrackingError, } from "./errors.js";
|
|
10
10
|
import { decisionFromCandidates, } from "./organize.js";
|
|
@@ -385,7 +385,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
385
385
|
}
|
|
386
386
|
}
|
|
387
387
|
// Company resolved — resolve project/task (US-007B order).
|
|
388
|
-
|
|
388
|
+
let projectResolved = resolveProjectTask({
|
|
389
389
|
sessionId,
|
|
390
390
|
env,
|
|
391
391
|
trusted,
|
|
@@ -393,6 +393,14 @@ export async function reconcileObservation(obs, deps) {
|
|
|
393
393
|
hqRoot,
|
|
394
394
|
existingState: prior,
|
|
395
395
|
});
|
|
396
|
+
// Deterministic cwd projects belong to the cwd company. A higher-precedence
|
|
397
|
+
// identity UID must not adopt a project from an unrelated/unverified slug.
|
|
398
|
+
if (projectResolved?.source === "deterministic_cwd") {
|
|
399
|
+
const cwdCompany = companySlugFromCwd(cwd, hqRoot);
|
|
400
|
+
if (!resolvedCompany.slug || cwdCompany?.toLowerCase() !== resolvedCompany.slug.toLowerCase()) {
|
|
401
|
+
projectResolved = null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
396
404
|
const projectId = projectResolved?.projectId;
|
|
397
405
|
const taskId = projectResolved?.taskId;
|
|
398
406
|
// Trusted / deterministic auto-bind never asks.
|
|
@@ -475,6 +483,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
475
483
|
outboxOp = enqueueOutbox({
|
|
476
484
|
clientOperationId: obs.clientOperationId,
|
|
477
485
|
sessionId,
|
|
486
|
+
harness: obs.identity.harness,
|
|
478
487
|
companyUid: resolvedCompany.uid,
|
|
479
488
|
companySlug: resolvedCompany.slug,
|
|
480
489
|
projectId,
|