@indigoai-us/hq-cli 5.121.1 → 5.122.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 +34 -0
- package/dist/command-catalog.generated.d.ts +38 -4
- package/dist/command-catalog.generated.js +48 -4
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-enroll.d.ts +26 -0
- package/dist/commands/agent-enroll.js +63 -6
- package/dist/commands/agent.js +15 -1
- package/dist/commands/cloud-demote.js +3 -2
- package/dist/commands/cloud-provision.d.ts +11 -0
- package/dist/commands/cloud-provision.js +25 -0
- package/dist/commands/cloud-retire.d.ts +51 -0
- package/dist/commands/cloud-retire.js +154 -0
- package/dist/commands/mesh.js +26 -26
- package/dist/lib/agent-kit/adopt-identity.d.ts +27 -0
- package/dist/lib/agent-kit/adopt-identity.js +51 -0
- package/dist/lib/agent-kit/paths.d.ts +20 -0
- package/dist/lib/agent-kit/paths.js +61 -1
- package/dist/lib/doctor/checks/work-context.js +1 -1
- package/dist/lib/mesh/client.d.ts +3 -2
- package/dist/lib/mesh/client.js +3 -2
- package/dist/lib/mesh/live/backfill-held.d.ts +3 -2
- package/dist/lib/mesh/live/backfill-held.js +3 -2
- package/dist/lib/mesh/live/daemon/doctor.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/doctor.js +12 -2
- package/dist/lib/mesh/live/daemon/run.d.ts +7 -0
- package/dist/lib/mesh/live/daemon/run.js +41 -0
- package/dist/lib/mesh/live/flush.js +44 -2
- package/dist/lib/mesh/live/spool.d.ts +13 -0
- package/dist/lib/mesh/live/spool.js +67 -0
- package/dist/lib/work-context/config.d.ts +1 -4
- package/dist/lib/work-context/config.js +1 -5
- package/dist/lib/work-context/outbox.d.ts +8 -0
- package/dist/lib/work-context/outbox.js +47 -0
- package/dist/lib/work-context/reconcile.js +12 -2
- package/dist/lib/work-context/state.d.ts +23 -0
- package/dist/lib/work-context/state.js +67 -0
- package/package.json +1 -1
|
@@ -18,6 +18,8 @@ import { replayOutbox } from "../../../work-context/outbox.js";
|
|
|
18
18
|
import { workContextRoot } from "../../../work-context/paths.js";
|
|
19
19
|
import { createSessionEventsPoster, resolveVaultApiBase, } from "../session-events-client.js";
|
|
20
20
|
import { flushSessionEvents, HELD_RETRY_INTERVAL_MS, } from "../flush.js";
|
|
21
|
+
import { backfillHeldSessions, } from "../backfill-held.js";
|
|
22
|
+
import { reconcileObservation } from "../../../work-context/reconcile.js";
|
|
21
23
|
import { workMeshRoot, workMeshSpoolPath } from "../paths.js";
|
|
22
24
|
import { createVaultBoardReader, refreshBoundSessionBoards, BOARD_REFRESH_INTERVAL_MS, } from "./board-refresh.js";
|
|
23
25
|
import { createContract3Fetcher, realTimerHost, } from "./credentials.js";
|
|
@@ -31,6 +33,8 @@ import { noteHookSessionsFromSpoolFile, TRANSCRIPT_WATCH_INTERVAL_MS, Transcript
|
|
|
31
33
|
import { resolveMeshEmitMode, runsEmitPath, } from "./mode.js";
|
|
32
34
|
export const SPOOL_DEBOUNCE_MS = 2_000;
|
|
33
35
|
export const FLUSH_INTERVAL_MS = 10_000;
|
|
36
|
+
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
37
|
+
export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
34
38
|
/**
|
|
35
39
|
* Only producer appends to spool.jsonl should wake the watcher. Every other
|
|
36
40
|
* file in the work-mesh root (held.jsonl, spool.<ts>.claimed, held.<ts>.claimed,
|
|
@@ -121,6 +125,26 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
121
125
|
const deliver = createWorkSessionDeliverer({ token: t });
|
|
122
126
|
return replayOutbox(ctxRoot, deliver);
|
|
123
127
|
});
|
|
128
|
+
const backfillFn = deps.backfillHeld ??
|
|
129
|
+
(async () => {
|
|
130
|
+
const token = await getToken();
|
|
131
|
+
const deliver = createWorkSessionDeliverer({ token });
|
|
132
|
+
return backfillHeldSessions({
|
|
133
|
+
workMeshRoot: meshRoot,
|
|
134
|
+
workContextRoot: ctxRoot,
|
|
135
|
+
limit: deps.heldBackfillBatchSize ?? HELD_BACKFILL_STARTUP_BATCH_SIZE,
|
|
136
|
+
env,
|
|
137
|
+
reconcile: (observation) => reconcileObservation(observation, {
|
|
138
|
+
root: ctxRoot,
|
|
139
|
+
env,
|
|
140
|
+
deliver,
|
|
141
|
+
validateMembership: async (slug) => {
|
|
142
|
+
const membership = await resolveActiveMembershipCompany(token, slug);
|
|
143
|
+
return membership ? { uid: membership.companyUid } : false;
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
});
|
|
124
148
|
const boardLast = new Map();
|
|
125
149
|
const refreshFn = deps.refreshBoards ??
|
|
126
150
|
(async () => {
|
|
@@ -253,6 +277,23 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
253
277
|
}
|
|
254
278
|
flushInFlight = (async () => {
|
|
255
279
|
try {
|
|
280
|
+
// A held session has already ended, so no later hook will naturally
|
|
281
|
+
// revisit its context. Repair a bounded batch before the first flush;
|
|
282
|
+
// recovered state is then retried immediately in this same cycle.
|
|
283
|
+
if (reason === "start") {
|
|
284
|
+
try {
|
|
285
|
+
const recovered = await backfillFn();
|
|
286
|
+
if (recovered.reconciled > 0 ||
|
|
287
|
+
recovered.unresolved > 0 ||
|
|
288
|
+
recovered.errors > 0) {
|
|
289
|
+
log(dir, `held backfill: considered=${recovered.considered} reconciled=${recovered.reconciled} unresolved=${recovered.unresolved} errors=${recovered.errors}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
294
|
+
log(dir, `held backfill error: ${msg.slice(0, 200)}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
256
297
|
// Record hook-emitted session ids from spool/held before claim/rename.
|
|
257
298
|
const at = now().getTime();
|
|
258
299
|
noteHookSessionsFromSpoolFile(workMeshSpoolPath(meshRoot), lastHookEventAt, at);
|
|
@@ -10,7 +10,10 @@
|
|
|
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";
|
|
14
|
+
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey, } from "../../work-context/repo-remote.js";
|
|
13
15
|
import { readSessionState } from "../../work-context/state.js";
|
|
16
|
+
import { repairAckedRegisterStates } from "../../work-context/outbox.js";
|
|
14
17
|
import { workContextRoot } from "../../work-context/paths.js";
|
|
15
18
|
import { defaultSleep, FLUSH_MAX_ATTEMPTS, fullJitterDelayMs, } from "./backoff.js";
|
|
16
19
|
import { LOCAL_ONLY_FIELDS, stripLocalOnlyFields } from "./format-spool-line.js";
|
|
@@ -60,6 +63,35 @@ function unwrapHeld(obj) {
|
|
|
60
63
|
function heldEnvelope(event, heldReason, heldAt) {
|
|
61
64
|
return JSON.stringify({ event, heldReason, heldAt });
|
|
62
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* A seven-day retention rule must never beat local recovery. An event can
|
|
68
|
+
* still carry enough trusted/deterministic context (or have a configured
|
|
69
|
+
* device default) to reconcile its session on the next daemon startup.
|
|
70
|
+
*/
|
|
71
|
+
function isLocallyReconcilableHeldEvent(event, state, workContextRoot) {
|
|
72
|
+
const sessionId = sessionIdOf(event);
|
|
73
|
+
if (!sessionId || state?.companyUid)
|
|
74
|
+
return Boolean(sessionId && state?.companyUid);
|
|
75
|
+
const text = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
76
|
+
const cwd = text(event.cwd);
|
|
77
|
+
const hqRoot = text(event.hqRoot);
|
|
78
|
+
const remoteOwnerSlug = cwd
|
|
79
|
+
? deriveRemoteOwnerSlug({ cwd, hqRoot }) ?? undefined
|
|
80
|
+
: undefined;
|
|
81
|
+
const repoIdentityKey = cwd ? deriveRepoIdentityKey({ cwd }) : null;
|
|
82
|
+
return (resolveCompany({
|
|
83
|
+
root: workContextRoot,
|
|
84
|
+
sessionId,
|
|
85
|
+
existingState: state,
|
|
86
|
+
trusted: text(event.companySlug)
|
|
87
|
+
? { companySlug: text(event.companySlug) }
|
|
88
|
+
: undefined,
|
|
89
|
+
cwd,
|
|
90
|
+
hqRoot,
|
|
91
|
+
remoteOwnerSlug,
|
|
92
|
+
repoIdentityKey,
|
|
93
|
+
}).status === "resolved");
|
|
94
|
+
}
|
|
63
95
|
function deadLetterEnvelope(event, reason, responseCode, at) {
|
|
64
96
|
return JSON.stringify({ event, reason, responseCode, at });
|
|
65
97
|
}
|
|
@@ -157,10 +189,16 @@ export async function flushSessionEvents(deps) {
|
|
|
157
189
|
summary.deadLettered += 1;
|
|
158
190
|
continue;
|
|
159
191
|
}
|
|
160
|
-
// Expire held lines older than 7 days
|
|
192
|
+
// Expire held lines older than 7 days only when local recovery cannot
|
|
193
|
+
// still resolve the session. The daemon backfill runs before startup
|
|
194
|
+
// flush, so retaining these lines gives that deterministic repair a
|
|
195
|
+
// chance rather than silently losing attributable work.
|
|
161
196
|
if (heldAt) {
|
|
162
197
|
const age = now().getTime() - Date.parse(heldAt);
|
|
163
|
-
|
|
198
|
+
const state = readSessionState(sessionIdOf(event), deps.workContextRoot);
|
|
199
|
+
if (Number.isFinite(age) &&
|
|
200
|
+
age > HELD_TTL_MS &&
|
|
201
|
+
!isLocallyReconcilableHeldEvent(event, state, deps.workContextRoot)) {
|
|
164
202
|
appendDeadLetterLine(deadLetterEnvelope(event, "HELD_EXPIRED", "held_ttl", now().toISOString()), deps.workMeshRoot);
|
|
165
203
|
summary.deadLettered += 1;
|
|
166
204
|
continue;
|
|
@@ -171,6 +209,10 @@ export async function flushSessionEvents(deps) {
|
|
|
171
209
|
}
|
|
172
210
|
}
|
|
173
211
|
const bySession = groupBySession(collected);
|
|
212
|
+
// Older clients acknowledged register operations without copying the
|
|
213
|
+
// authoritative company into the session projection. Repair those files
|
|
214
|
+
// before classification so a valid receipt cannot be held as NEEDS_COMPANY.
|
|
215
|
+
repairAckedRegisterStates(deps.workContextRoot, new Set(bySession.keys()), now);
|
|
174
216
|
const markDisposed = (event) => {
|
|
175
217
|
pendingRestore.delete(event);
|
|
176
218
|
};
|
|
@@ -49,4 +49,17 @@ export declare function recoverOrphanClaims(root: string): {
|
|
|
49
49
|
export declare function removeClaimFile(claimPath: string | null): void;
|
|
50
50
|
export declare function deadLetterNonEmpty(root?: string): boolean;
|
|
51
51
|
export declare function countJsonlLines(filePath: string): number;
|
|
52
|
+
/** Local queues that still lack a usable company attribution. */
|
|
53
|
+
export interface UnattributedEventCounts {
|
|
54
|
+
spool: number;
|
|
55
|
+
held: number;
|
|
56
|
+
deadLetter: number;
|
|
57
|
+
total: number;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Count the actionable, unfiled events across all local delivery queues.
|
|
61
|
+
* This deliberately does not inspect session state: the signal must stay
|
|
62
|
+
* visible even when state is absent or corrupt.
|
|
63
|
+
*/
|
|
64
|
+
export declare function countUnattributedEvents(root?: string): UnattributedEventCounts;
|
|
52
65
|
//# sourceMappingURL=spool.d.ts.map
|
|
@@ -195,4 +195,71 @@ export function countJsonlLines(filePath) {
|
|
|
195
195
|
}
|
|
196
196
|
return n;
|
|
197
197
|
}
|
|
198
|
+
function record(value) {
|
|
199
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
200
|
+
? value
|
|
201
|
+
: null;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* A queue line can be a raw event, a held envelope, or a dead-letter
|
|
205
|
+
* envelope. Count the line when it is explicitly marked NEEDS_COMPANY/NONE,
|
|
206
|
+
* or when its underlying event has no company identity at all.
|
|
207
|
+
*/
|
|
208
|
+
function isUnattributedLine(line) {
|
|
209
|
+
try {
|
|
210
|
+
const outer = record(JSON.parse(line));
|
|
211
|
+
if (!outer)
|
|
212
|
+
return false;
|
|
213
|
+
const event = record(outer.event) ?? outer;
|
|
214
|
+
const markers = [
|
|
215
|
+
outer.heldReason,
|
|
216
|
+
outer.reason,
|
|
217
|
+
outer.contextStatus,
|
|
218
|
+
event.heldReason,
|
|
219
|
+
event.reason,
|
|
220
|
+
event.contextStatus,
|
|
221
|
+
event.company,
|
|
222
|
+
event.companyUid,
|
|
223
|
+
event.companySlug,
|
|
224
|
+
];
|
|
225
|
+
if (markers.some((value) => typeof value === "string" &&
|
|
226
|
+
["NEEDS_COMPANY", "NONE"].includes(value.trim().toUpperCase()))) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
return ![
|
|
230
|
+
event.companyUid,
|
|
231
|
+
event.companySlug,
|
|
232
|
+
event.company,
|
|
233
|
+
].some((value) => typeof value === "string" && value.trim().length > 0);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// Malformed lines are reported separately by the dead-letter count.
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function countUnattributedJsonlLines(filePath) {
|
|
241
|
+
if (!fs.existsSync(filePath))
|
|
242
|
+
return 0;
|
|
243
|
+
try {
|
|
244
|
+
return fs
|
|
245
|
+
.readFileSync(filePath, "utf8")
|
|
246
|
+
.split("\n")
|
|
247
|
+
.filter((line) => line.trim() && isUnattributedLine(line)).length;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return 0;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Count the actionable, unfiled events across all local delivery queues.
|
|
255
|
+
* This deliberately does not inspect session state: the signal must stay
|
|
256
|
+
* visible even when state is absent or corrupt.
|
|
257
|
+
*/
|
|
258
|
+
export function countUnattributedEvents(root) {
|
|
259
|
+
const meshRoot = root ?? workMeshRoot();
|
|
260
|
+
const spool = countUnattributedJsonlLines(workMeshSpoolPath(meshRoot));
|
|
261
|
+
const held = countUnattributedJsonlLines(workMeshHeldPath(meshRoot));
|
|
262
|
+
const deadLetter = countUnattributedJsonlLines(workMeshDeadLetterPath(meshRoot));
|
|
263
|
+
return { spool, held, deadLetter, total: spool + held + deadLetter };
|
|
264
|
+
}
|
|
198
265
|
//# sourceMappingURL=spool.js.map
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Device-local default company preference (~/.hq/work-context/config.json).
|
|
3
3
|
* Preference only — never authority. activeCompany is never treated as default.
|
|
4
|
-
* Default-company mode ships dark until migrationCapability (US-017A).
|
|
5
4
|
*/
|
|
6
5
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
7
6
|
export declare const DEVICE_CONFIG_SCHEMA_VERSION: 1;
|
|
@@ -61,9 +60,7 @@ export interface DeviceConfigDeps {
|
|
|
61
60
|
}
|
|
62
61
|
export declare function readDeviceConfig(deps: Pick<DeviceConfigDeps, "root">): WorkContextDeviceConfig;
|
|
63
62
|
export declare function getDefaultCompany(deps: Pick<DeviceConfigDeps, "root">): DeviceDefaultCompany | null;
|
|
64
|
-
export declare function setDefaultCompany(slug: string, deps: DeviceConfigDeps
|
|
65
|
-
allowWithoutMigration?: boolean;
|
|
66
|
-
}): Promise<WorkContextDeviceConfig>;
|
|
63
|
+
export declare function setDefaultCompany(slug: string, deps: DeviceConfigDeps): Promise<WorkContextDeviceConfig>;
|
|
67
64
|
export declare function clearDefaultCompany(deps: DeviceConfigDeps): WorkContextDeviceConfig;
|
|
68
65
|
/**
|
|
69
66
|
* Read the persisted company mapping for a repo identity key (gap 4).
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Device-local default company preference (~/.hq/work-context/config.json).
|
|
3
3
|
* Preference only — never authority. activeCompany is never treated as default.
|
|
4
|
-
* Default-company mode ships dark until migrationCapability (US-017A).
|
|
5
4
|
*/
|
|
6
5
|
import * as fs from "node:fs";
|
|
7
6
|
import * as path from "node:path";
|
|
8
7
|
import { atomicWriteJson, ensureOwnerDir, resolveRealTarget } from "./atomic.js";
|
|
9
|
-
import {
|
|
8
|
+
import { DefaultCompanyUnavailableError, UnsafeConfigPathError, WorkContextError, } from "./errors.js";
|
|
10
9
|
import { workContextConfigPath, workContextRoot } from "./paths.js";
|
|
11
10
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
12
11
|
export const DEVICE_CONFIG_SCHEMA_VERSION = 1;
|
|
@@ -116,9 +115,6 @@ export async function setDefaultCompany(slug, deps) {
|
|
|
116
115
|
if (!trimmed || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed)) {
|
|
117
116
|
throw new WorkContextError("InvalidCompanySlug", `Invalid company slug: ${slug}`);
|
|
118
117
|
}
|
|
119
|
-
if (!deps.migrationCapability && !deps.allowWithoutMigration) {
|
|
120
|
-
throw new DefaultCompanyLockedError();
|
|
121
|
-
}
|
|
122
118
|
if (!deps.validateMembership) {
|
|
123
119
|
throw new DefaultCompanyUnavailableError(`Cannot verify membership for company "${trimmed}" (no membership validator)`);
|
|
124
120
|
}
|
|
@@ -76,6 +76,14 @@ export declare function enqueueOutbox(input: OutboxEnqueueInput, root: string):
|
|
|
76
76
|
export declare function readOutboxOperation(operationId: string, root: string): OutboxOperation | null;
|
|
77
77
|
export declare function updateOutboxOperation(op: OutboxOperation, root: string): void;
|
|
78
78
|
export declare function markOutboxAcked(operationId: string, root: string, receiptId: string, now?: () => Date): OutboxOperation | null;
|
|
79
|
+
/**
|
|
80
|
+
* Write server-acknowledged register scope into the session projection.
|
|
81
|
+
* This closes the outbox → emitter handoff for both direct reconciliation and
|
|
82
|
+
* daemon replay. Non-register or incomplete legacy operations are ignored.
|
|
83
|
+
*/
|
|
84
|
+
export declare function writeAckedRegisterState(op: OutboxOperation, root: string, receiptId: string, now?: () => Date): void;
|
|
85
|
+
/** Repair older unresolved session projections from acknowledged registrations. */
|
|
86
|
+
export declare function repairAckedRegisterStates(root: string, sessionIds?: ReadonlySet<string>, now?: () => Date): number;
|
|
79
87
|
export declare function markOutboxQueued(operationId: string, root: string, errorCode: string, now?: () => Date, random?: () => number): OutboxOperation | null;
|
|
80
88
|
export declare function markOutboxQuarantined(operationId: string, root: string, errorCode: string, now?: () => Date): OutboxOperation | null;
|
|
81
89
|
/**
|
|
@@ -8,6 +8,7 @@ import * as path from "node:path";
|
|
|
8
8
|
import { WORK_CONTEXT_CONTRACT_VERSION } from "./contract.js";
|
|
9
9
|
import { atomicWriteJson, ensureOwnerDir } from "./atomic.js";
|
|
10
10
|
import { NotTrackingError } from "./errors.js";
|
|
11
|
+
import { readSessionState, writeAcknowledgedRegisterBinding, } from "./state.js";
|
|
11
12
|
import { isSafeWorkContextSegment, workContextOutboxDir, workContextOutboxPath, } from "./paths.js";
|
|
12
13
|
/** Fields allowed on a durable outbox operation (privacy allowlist). */
|
|
13
14
|
export const OUTBOX_ALLOWLIST = [
|
|
@@ -258,6 +259,51 @@ export function markOutboxAcked(operationId, root, receiptId, now = () => new Da
|
|
|
258
259
|
updateOutboxOperation(op, root);
|
|
259
260
|
return op;
|
|
260
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* Write server-acknowledged register scope into the session projection.
|
|
264
|
+
* This closes the outbox → emitter handoff for both direct reconciliation and
|
|
265
|
+
* daemon replay. Non-register or incomplete legacy operations are ignored.
|
|
266
|
+
*/
|
|
267
|
+
export function writeAckedRegisterState(op, root, receiptId, now = () => new Date()) {
|
|
268
|
+
if (op.kind !== "register" || !op.companyUid)
|
|
269
|
+
return;
|
|
270
|
+
writeAcknowledgedRegisterBinding({
|
|
271
|
+
kind: "register",
|
|
272
|
+
sessionId: op.sessionId,
|
|
273
|
+
companyUid: op.companyUid,
|
|
274
|
+
companySlug: op.companySlug,
|
|
275
|
+
projectId: op.projectId,
|
|
276
|
+
taskId: op.taskId,
|
|
277
|
+
receiptId,
|
|
278
|
+
}, root, now);
|
|
279
|
+
}
|
|
280
|
+
/** Repair older unresolved session projections from acknowledged registrations. */
|
|
281
|
+
export function repairAckedRegisterStates(root, sessionIds, now = () => new Date()) {
|
|
282
|
+
let repaired = 0;
|
|
283
|
+
for (const op of listOutboxOperations(root)) {
|
|
284
|
+
if (op.delivery !== "acked" ||
|
|
285
|
+
!op.receiptId ||
|
|
286
|
+
op.kind !== "register" ||
|
|
287
|
+
!op.companyUid ||
|
|
288
|
+
(sessionIds && !sessionIds.has(op.sessionId))) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const before = readSessionState(op.sessionId, root);
|
|
292
|
+
const after = writeAcknowledgedRegisterBinding({
|
|
293
|
+
kind: "register",
|
|
294
|
+
sessionId: op.sessionId,
|
|
295
|
+
companyUid: op.companyUid,
|
|
296
|
+
companySlug: op.companySlug,
|
|
297
|
+
projectId: op.projectId,
|
|
298
|
+
taskId: op.taskId,
|
|
299
|
+
receiptId: op.receiptId,
|
|
300
|
+
}, root, now);
|
|
301
|
+
if (after && (before?.contextStatus === "unresolved" || !before?.companyUid)) {
|
|
302
|
+
repaired += 1;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return repaired;
|
|
306
|
+
}
|
|
261
307
|
export function markOutboxQueued(operationId, root, errorCode, now = () => new Date(), random = Math.random) {
|
|
262
308
|
const op = readOutboxOperation(operationId, root);
|
|
263
309
|
if (!op)
|
|
@@ -461,6 +507,7 @@ export async function replayOutbox(root, deliver, opts = {}) {
|
|
|
461
507
|
const result = await deliver(op);
|
|
462
508
|
if (result.ok) {
|
|
463
509
|
markOutboxAcked(op.operationId, root, result.receiptId, now);
|
|
510
|
+
writeAckedRegisterState(op, root, result.receiptId, now);
|
|
464
511
|
delivered += 1;
|
|
465
512
|
}
|
|
466
513
|
else if (result.retryable) {
|
|
@@ -10,7 +10,7 @@ import { EXIT_INVALID_IDENTITY, EXIT_NOT_TRACKING, EXIT_OK, InvalidDecisionOrigi
|
|
|
10
10
|
import { decisionFromCandidates, } from "./organize.js";
|
|
11
11
|
import { resolveProjectTask, shouldAskAfter } from "./project.js";
|
|
12
12
|
import { deriveRemoteOwnerSlug, deriveRepoIdentityKey } from "./repo-remote.js";
|
|
13
|
-
import { enqueueOutbox, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, } from "./outbox.js";
|
|
13
|
+
import { enqueueOutbox, markOutboxAcked, markOutboxQuarantined, markOutboxQueued, replayOutbox, writeAckedRegisterState, } from "./outbox.js";
|
|
14
14
|
import { mergeLocalSessionFields, readSessionState, writeSessionState, } from "./state.js";
|
|
15
15
|
function resultOf(parts) {
|
|
16
16
|
const out = {
|
|
@@ -482,6 +482,15 @@ export async function reconcileObservation(obs, deps) {
|
|
|
482
482
|
registerClassification === "unresolved") {
|
|
483
483
|
registerClassification = "needs_project";
|
|
484
484
|
}
|
|
485
|
+
// Do not let a weak later observation erase a server-acknowledged company
|
|
486
|
+
// binding. Keep the result unresolved so the normal candidate threshold can
|
|
487
|
+
// still advance the session, while preserving the durable emitter projection.
|
|
488
|
+
const stateClassification = registerClassification === "unresolved" &&
|
|
489
|
+
prior?.contextStatus === "needs_project" &&
|
|
490
|
+
prior.companyUid === resolvedCompany.uid &&
|
|
491
|
+
prior.bindingEpisodeId
|
|
492
|
+
? "needs_project"
|
|
493
|
+
: registerClassification;
|
|
485
494
|
let outboxOp;
|
|
486
495
|
try {
|
|
487
496
|
outboxOp = enqueueOutbox({
|
|
@@ -524,7 +533,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
524
533
|
sessionId,
|
|
525
534
|
companyUid: resolvedCompany.uid,
|
|
526
535
|
companySlug: resolvedCompany.slug,
|
|
527
|
-
contextStatus:
|
|
536
|
+
contextStatus: stateClassification,
|
|
528
537
|
projectId,
|
|
529
538
|
taskId,
|
|
530
539
|
updatedAt: nowIso,
|
|
@@ -578,6 +587,7 @@ export async function reconcileObservation(obs, deps) {
|
|
|
578
587
|
const delivered = await deps.deliver(outboxOp);
|
|
579
588
|
if (delivered.ok) {
|
|
580
589
|
markOutboxAcked(outboxOp.operationId, deps.root, delivered.receiptId, nowFn);
|
|
590
|
+
writeAckedRegisterState(outboxOp, deps.root, delivered.receiptId, nowFn);
|
|
581
591
|
// Persist binding episode on success for bound registrations.
|
|
582
592
|
if (registerClassification === "bound") {
|
|
583
593
|
writeSessionState(mergeLocalSessionFields({
|
|
@@ -91,6 +91,20 @@ export interface SessionStateFile {
|
|
|
91
91
|
transcriptMarker?: "from transcript";
|
|
92
92
|
updatedAt: string;
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Metadata from a register operation that the control plane has acknowledged.
|
|
96
|
+
* Kept structural here so the state projection does not need a runtime
|
|
97
|
+
* dependency on the outbox implementation.
|
|
98
|
+
*/
|
|
99
|
+
export interface AcknowledgedRegisterBinding {
|
|
100
|
+
kind: "register";
|
|
101
|
+
sessionId: string;
|
|
102
|
+
companyUid?: string;
|
|
103
|
+
companySlug?: string;
|
|
104
|
+
projectId?: string;
|
|
105
|
+
taskId?: string;
|
|
106
|
+
receiptId: string;
|
|
107
|
+
}
|
|
94
108
|
/** Allowlisted keys that may appear in the durable state projection. */
|
|
95
109
|
export declare const SESSION_STATE_ALLOWLIST: Set<string>;
|
|
96
110
|
export declare function readSessionState(sessionId: string, root: string): SessionStateFile | null;
|
|
@@ -102,6 +116,15 @@ export declare function projectSessionState(state: SessionStateFile): SessionSta
|
|
|
102
116
|
* and reconcile updates do not clobber each other.
|
|
103
117
|
*/
|
|
104
118
|
export declare function mergeLocalSessionFields(next: SessionStateFile, prior: SessionStateFile | null): SessionStateFile;
|
|
119
|
+
/**
|
|
120
|
+
* Upgrade the local projection after the server acknowledges a register.
|
|
121
|
+
*
|
|
122
|
+
* A receipt makes the operation's company authoritative, but it must never
|
|
123
|
+
* erase an explicit untracked choice, a conflicting existing company, or a
|
|
124
|
+
* more specific binding for the same company. This is also used to repair
|
|
125
|
+
* state files created by older clients before this write-back existed.
|
|
126
|
+
*/
|
|
127
|
+
export declare function writeAcknowledgedRegisterBinding(binding: AcknowledgedRegisterBinding, root: string, now?: () => Date): SessionStateFile | null;
|
|
105
128
|
/** True when a durable state file was written by the hook / reconcile path. */
|
|
106
129
|
export declare function isHookWrittenSessionState(state: SessionStateFile | null): boolean;
|
|
107
130
|
/** Authoritative prior scope usable for company precedence. */
|
|
@@ -136,6 +136,73 @@ export function mergeLocalSessionFields(next, prior) {
|
|
|
136
136
|
}
|
|
137
137
|
return out;
|
|
138
138
|
}
|
|
139
|
+
function acknowledgedStatus(binding) {
|
|
140
|
+
if (!binding.companyUid)
|
|
141
|
+
return null;
|
|
142
|
+
if (binding.projectId && binding.taskId)
|
|
143
|
+
return "bound";
|
|
144
|
+
if (binding.projectId)
|
|
145
|
+
return "needs_task";
|
|
146
|
+
return "needs_project";
|
|
147
|
+
}
|
|
148
|
+
function bindingStrength(state) {
|
|
149
|
+
if (state.contextStatus === "bound" && state.projectId && state.taskId)
|
|
150
|
+
return 3;
|
|
151
|
+
if (state.contextStatus === "needs_task" && state.projectId)
|
|
152
|
+
return 2;
|
|
153
|
+
if (state.contextStatus === "needs_project")
|
|
154
|
+
return 1;
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Upgrade the local projection after the server acknowledges a register.
|
|
159
|
+
*
|
|
160
|
+
* A receipt makes the operation's company authoritative, but it must never
|
|
161
|
+
* erase an explicit untracked choice, a conflicting existing company, or a
|
|
162
|
+
* more specific binding for the same company. This is also used to repair
|
|
163
|
+
* state files created by older clients before this write-back existed.
|
|
164
|
+
*/
|
|
165
|
+
export function writeAcknowledgedRegisterBinding(binding, root, now = () => new Date()) {
|
|
166
|
+
const acknowledged = acknowledgedStatus(binding);
|
|
167
|
+
if (!acknowledged || !isSafeWorkContextSegment(binding.sessionId))
|
|
168
|
+
return null;
|
|
169
|
+
const prior = readSessionState(binding.sessionId, root);
|
|
170
|
+
// Reconcile always writes the local projection before network delivery. Do
|
|
171
|
+
// not manufacture session files when replay sees an orphaned legacy outbox
|
|
172
|
+
// record with no corresponding tracked session.
|
|
173
|
+
if (!prior)
|
|
174
|
+
return null;
|
|
175
|
+
if (prior.contextStatus === "untracked" ||
|
|
176
|
+
(prior.companyUid && prior.companyUid !== binding.companyUid)) {
|
|
177
|
+
return prior;
|
|
178
|
+
}
|
|
179
|
+
const acknowledgementStrength = acknowledged === "bound" ? 3 : acknowledged === "needs_task" ? 2 : 1;
|
|
180
|
+
const priorStrength = bindingStrength(prior);
|
|
181
|
+
const preservePriorBinding = Boolean(prior.companyUid === binding.companyUid && priorStrength > acknowledgementStrength);
|
|
182
|
+
const next = mergeLocalSessionFields({
|
|
183
|
+
contractVersion: WORK_CONTEXT_CONTRACT_VERSION,
|
|
184
|
+
sessionId: binding.sessionId,
|
|
185
|
+
companyUid: binding.companyUid,
|
|
186
|
+
companySlug: binding.companySlug ?? prior.companySlug,
|
|
187
|
+
contextStatus: preservePriorBinding ? prior.contextStatus : acknowledged,
|
|
188
|
+
projectId: preservePriorBinding ? prior.projectId : binding.projectId,
|
|
189
|
+
taskId: preservePriorBinding ? prior.taskId : binding.taskId,
|
|
190
|
+
bindingEpisodeId: preservePriorBinding
|
|
191
|
+
? prior.bindingEpisodeId ?? binding.receiptId
|
|
192
|
+
: binding.receiptId,
|
|
193
|
+
updatedAt: now().toISOString(),
|
|
194
|
+
}, prior);
|
|
195
|
+
if (prior.companyUid === next.companyUid &&
|
|
196
|
+
prior.companySlug === next.companySlug &&
|
|
197
|
+
prior.contextStatus === next.contextStatus &&
|
|
198
|
+
prior.projectId === next.projectId &&
|
|
199
|
+
prior.taskId === next.taskId &&
|
|
200
|
+
prior.bindingEpisodeId === next.bindingEpisodeId) {
|
|
201
|
+
return prior;
|
|
202
|
+
}
|
|
203
|
+
writeSessionState(next, root);
|
|
204
|
+
return next;
|
|
205
|
+
}
|
|
139
206
|
/** True when a durable state file was written by the hook / reconcile path. */
|
|
140
207
|
export function isHookWrittenSessionState(state) {
|
|
141
208
|
if (!state)
|