@gajae-code/coding-agent 0.4.5 → 0.5.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 +43 -0
- package/dist/types/commands/harness.d.ts +3 -0
- package/dist/types/config/model-profile-activation.d.ts +11 -2
- package/dist/types/config/model-profiles.d.ts +7 -0
- package/dist/types/config/model-registry.d.ts +3 -0
- package/dist/types/config/model-resolver.d.ts +2 -0
- package/dist/types/config/models-config-schema.d.ts +30 -0
- package/dist/types/config/settings-schema.d.ts +4 -3
- package/dist/types/gjc-runtime/team-runtime.d.ts +0 -1
- package/dist/types/gjc-runtime/tmux-common.d.ts +3 -0
- package/dist/types/harness-control-plane/owner.d.ts +1 -1
- package/dist/types/harness-control-plane/receipt-spool.d.ts +19 -0
- package/dist/types/harness-control-plane/state-machine.d.ts +6 -1
- package/dist/types/harness-control-plane/types.d.ts +4 -0
- package/dist/types/hindsight/mental-models.d.ts +5 -5
- package/dist/types/modes/components/model-selector.d.ts +1 -12
- package/dist/types/modes/rpc/rpc-client.d.ts +2 -2
- package/dist/types/modes/rpc/rpc-types.d.ts +4 -1
- package/dist/types/sdk.d.ts +5 -0
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/session/blob-store.d.ts +20 -1
- package/dist/types/session/session-manager.d.ts +24 -6
- package/dist/types/session/streaming-output.d.ts +3 -2
- package/dist/types/session/tool-choice-queue.d.ts +6 -0
- package/dist/types/task/receipt.d.ts +1 -0
- package/dist/types/task/types.d.ts +7 -0
- package/dist/types/thinking-metadata.d.ts +16 -0
- package/dist/types/thinking.d.ts +3 -12
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/resolve.d.ts +0 -10
- package/dist/types/utils/tool-choice.d.ts +14 -1
- package/package.json +7 -7
- package/src/cli.ts +8 -4
- package/src/commands/harness.ts +36 -2
- package/src/commands/launch.ts +2 -2
- package/src/commands/session.ts +3 -1
- package/src/config/model-profile-activation.ts +15 -3
- package/src/config/model-profiles.ts +255 -56
- package/src/config/model-resolver.ts +9 -6
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +6 -3
- package/src/coordinator-mcp/server.ts +54 -23
- package/src/cursor.ts +16 -2
- package/src/defaults/gjc/skills/team/SKILL.md +3 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +8 -2
- package/src/export/html/index.ts +13 -9
- package/src/gjc-runtime/team-runtime.ts +33 -7
- package/src/gjc-runtime/tmux-common.ts +15 -0
- package/src/gjc-runtime/tmux-sessions.ts +19 -11
- package/src/gjc-runtime/ultragoal-runtime.ts +505 -41
- package/src/gjc-runtime/workflow-manifest.generated.json +27 -1
- package/src/gjc-runtime/workflow-manifest.ts +16 -1
- package/src/harness-control-plane/owner.ts +78 -27
- package/src/harness-control-plane/receipt-spool.ts +128 -0
- package/src/harness-control-plane/state-machine.ts +27 -6
- package/src/harness-control-plane/storage.ts +23 -0
- package/src/harness-control-plane/types.ts +4 -0
- package/src/hindsight/mental-models.ts +17 -16
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/modes/components/assistant-message.ts +26 -14
- package/src/modes/components/diff.ts +97 -0
- package/src/modes/components/model-selector.ts +353 -181
- package/src/modes/components/tool-execution.ts +30 -13
- package/src/modes/controllers/selector-controller.ts +33 -42
- package/src/modes/rpc/rpc-client.ts +3 -2
- package/src/modes/rpc/rpc-mode.ts +44 -14
- package/src/modes/rpc/rpc-types.ts +5 -2
- package/src/modes/shared/agent-wire/command-dispatch.ts +10 -5
- package/src/modes/shared/agent-wire/command-validation.ts +11 -0
- package/src/sdk.ts +29 -2
- package/src/secrets/obfuscator.ts +102 -27
- package/src/session/agent-session.ts +105 -20
- package/src/session/blob-store.ts +89 -3
- package/src/session/session-manager.ts +309 -58
- package/src/session/streaming-output.ts +185 -122
- package/src/session/tool-choice-queue.ts +23 -0
- package/src/task/executor.ts +69 -6
- package/src/task/receipt.ts +5 -0
- package/src/task/render.ts +21 -1
- package/src/task/types.ts +8 -0
- package/src/thinking-metadata.ts +51 -0
- package/src/thinking.ts +26 -46
- package/src/tools/bash.ts +1 -1
- package/src/tools/index.ts +2 -0
- package/src/tools/resolve.ts +93 -18
- package/src/utils/edit-mode.ts +1 -1
- package/src/utils/tool-choice.ts +45 -16
|
@@ -20,6 +20,7 @@ import { ControlServer, type EndpointRequest } from "./control-endpoint";
|
|
|
20
20
|
import { defaultFinalizeChecks, type FinalizeChecks, runFinalize, type ValidationCommandSpec } from "./finalize";
|
|
21
21
|
import { type OperateResult, operate } from "./operate";
|
|
22
22
|
import { preserveDirtyWorktree } from "./preserve";
|
|
23
|
+
import { RECEIPT_SPOOL_DIR_ENV, withReceiptSpoolDir } from "./receipt-spool";
|
|
23
24
|
import {
|
|
24
25
|
buildReceipt,
|
|
25
26
|
type ReceiptSubject,
|
|
@@ -28,8 +29,7 @@ import {
|
|
|
28
29
|
type VanishEvidence,
|
|
29
30
|
validateReceipt,
|
|
30
31
|
} from "./receipts";
|
|
31
|
-
import type
|
|
32
|
-
import { singleFlightAccept } from "./rpc-adapter";
|
|
32
|
+
import { type HarnessRpc, type RpcStateSnapshot, singleFlightAccept } from "./rpc-adapter";
|
|
33
33
|
import {
|
|
34
34
|
acquireLease,
|
|
35
35
|
canWriteEvents,
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
releaseLease,
|
|
40
40
|
type SessionLease,
|
|
41
41
|
} from "./session-lease";
|
|
42
|
-
import { buildStateView, nextAllowedActions } from "./state-machine";
|
|
42
|
+
import { buildStateView, nextAllowedActions, submitUnavailableReason } from "./state-machine";
|
|
43
43
|
import {
|
|
44
44
|
appendEvent,
|
|
45
45
|
controlSocketPath,
|
|
@@ -200,11 +200,6 @@ export class RuntimeOwner {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
async #emitMapped(mapped: NonNullable<ReturnType<typeof observeRpcOutboundFrame>>): Promise<void> {
|
|
203
|
-
await this.#emit(
|
|
204
|
-
mapped.severity,
|
|
205
|
-
mapped.kind,
|
|
206
|
-
mapped.signal ? { ...mapped.evidence, signal: mapped.signal } : mapped.evidence,
|
|
207
|
-
);
|
|
208
203
|
if (mapped.kind === "rpc_agent_completed") {
|
|
209
204
|
const state = await readSessionState(this.#opts.root, this.#opts.sessionId);
|
|
210
205
|
if (
|
|
@@ -218,6 +213,11 @@ export class RuntimeOwner {
|
|
|
218
213
|
await writeSessionState(this.#opts.root, state);
|
|
219
214
|
}
|
|
220
215
|
}
|
|
216
|
+
await this.#emit(
|
|
217
|
+
mapped.severity,
|
|
218
|
+
mapped.kind,
|
|
219
|
+
mapped.signal ? { ...mapped.evidence, signal: mapped.signal } : mapped.evidence,
|
|
220
|
+
);
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
#aggregateSignals(events: EventEnvelope[]): string[] {
|
|
@@ -233,6 +233,20 @@ export class RuntimeOwner {
|
|
|
233
233
|
return out;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
#eventSubmitGateReason(kind: string, evidence: Record<string, unknown>): string | null {
|
|
237
|
+
const reason = typeof evidence.reason === "string" ? evidence.reason : null;
|
|
238
|
+
const signal = typeof evidence.signal === "string" ? evidence.signal : null;
|
|
239
|
+
const rpcActive =
|
|
240
|
+
kind === "prompt_accepted" ||
|
|
241
|
+
reason === "pre-state-not-idle" ||
|
|
242
|
+
kind.startsWith("rpc_") ||
|
|
243
|
+
signal === "prompt-accepted" ||
|
|
244
|
+
signal === "streaming" ||
|
|
245
|
+
signal === "tool-call" ||
|
|
246
|
+
signal === "test-running";
|
|
247
|
+
return rpcActive ? "rpc-not-idle" : null;
|
|
248
|
+
}
|
|
249
|
+
|
|
236
250
|
async #emit(severity: Severity, kind: string, evidence: Record<string, unknown>): Promise<void> {
|
|
237
251
|
const lease = await readLease(this.#opts.root, this.#opts.sessionId);
|
|
238
252
|
// Single-writer guard: only emit while we still hold a live lease.
|
|
@@ -247,6 +261,7 @@ export class RuntimeOwner {
|
|
|
247
261
|
ownerLive: true,
|
|
248
262
|
blockers: [],
|
|
249
263
|
};
|
|
264
|
+
const submitGateReason = this.#eventSubmitGateReason(kind, evidence);
|
|
250
265
|
const envelope: EventEnvelope = {
|
|
251
266
|
eventId: randomUUID(),
|
|
252
267
|
cursor: ++this.#cursor,
|
|
@@ -255,21 +270,41 @@ export class RuntimeOwner {
|
|
|
255
270
|
kind,
|
|
256
271
|
state: view,
|
|
257
272
|
evidence,
|
|
258
|
-
nextAllowedActions: nextAllowedActions(view.lifecycle, true),
|
|
273
|
+
nextAllowedActions: nextAllowedActions(view.lifecycle, true, { submitUnavailableReason: submitGateReason }),
|
|
259
274
|
writer: { ownerId: this.ownerId, leaseEpoch: this.#leaseEpoch },
|
|
260
275
|
};
|
|
261
276
|
await appendEvent(this.#opts.root, this.#opts.sessionId, envelope);
|
|
262
277
|
}
|
|
263
278
|
|
|
264
|
-
#response(
|
|
279
|
+
#response(
|
|
280
|
+
state: SessionState,
|
|
281
|
+
evidence: Record<string, unknown>,
|
|
282
|
+
ok = true,
|
|
283
|
+
submitGateReason: string | null = null,
|
|
284
|
+
): PrimitiveResponse {
|
|
265
285
|
return {
|
|
266
286
|
ok,
|
|
267
287
|
state: buildStateView(state, true),
|
|
268
288
|
evidence,
|
|
269
|
-
nextAllowedActions: nextAllowedActions(state.lifecycle, true),
|
|
289
|
+
nextAllowedActions: nextAllowedActions(state.lifecycle, true, { submitUnavailableReason: submitGateReason }),
|
|
270
290
|
};
|
|
271
291
|
}
|
|
272
292
|
|
|
293
|
+
#submitGateReason(state: SessionState, rpcState: RpcStateSnapshot | null): string | null {
|
|
294
|
+
const rpcReason = rpcState
|
|
295
|
+
? rpcState.isStreaming || rpcState.steeringQueueDepth > 0 || rpcState.followupQueueDepth > 0
|
|
296
|
+
? "rpc-not-idle"
|
|
297
|
+
: null
|
|
298
|
+
: "rpc-not-live";
|
|
299
|
+
return submitUnavailableReason(state.lifecycle, true, rpcReason);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async #withReceiptSpoolFromInput<T>(input: Record<string, unknown>, fn: () => Promise<T>): Promise<T> {
|
|
303
|
+
const requested = input[RECEIPT_SPOOL_DIR_ENV];
|
|
304
|
+
if (typeof requested === "string" && requested.trim()) return withReceiptSpoolDir(requested, fn);
|
|
305
|
+
return fn();
|
|
306
|
+
}
|
|
307
|
+
|
|
273
308
|
async #handle(req: EndpointRequest): Promise<unknown> {
|
|
274
309
|
switch (req.verb) {
|
|
275
310
|
case "ping":
|
|
@@ -281,13 +316,13 @@ export class RuntimeOwner {
|
|
|
281
316
|
case "retire":
|
|
282
317
|
return this.#retire();
|
|
283
318
|
case "finalize":
|
|
284
|
-
return this.#finalize(req.input);
|
|
319
|
+
return this.#withReceiptSpoolFromInput(req.input, () => this.#finalize(req.input));
|
|
285
320
|
case "recover":
|
|
286
|
-
return this.#recover();
|
|
321
|
+
return this.#withReceiptSpoolFromInput(req.input, () => this.#recover());
|
|
287
322
|
case "validate":
|
|
288
|
-
return this.#validate();
|
|
323
|
+
return this.#withReceiptSpoolFromInput(req.input, () => this.#validate());
|
|
289
324
|
case "operate":
|
|
290
|
-
return this.#operate(req.input);
|
|
325
|
+
return this.#withReceiptSpoolFromInput(req.input, () => this.#operate(req.input));
|
|
291
326
|
default:
|
|
292
327
|
return { ok: false, error: `owner_unsupported_verb:${req.verb}` };
|
|
293
328
|
}
|
|
@@ -297,8 +332,10 @@ export class RuntimeOwner {
|
|
|
297
332
|
const state = await this.#loadState();
|
|
298
333
|
const workspace = state.handle.workspace;
|
|
299
334
|
let streaming = false;
|
|
335
|
+
let rpcState: RpcStateSnapshot | null = null;
|
|
300
336
|
try {
|
|
301
|
-
|
|
337
|
+
rpcState = await this.#opts.rpc.getState();
|
|
338
|
+
streaming = rpcState.isStreaming;
|
|
302
339
|
} catch {
|
|
303
340
|
streaming = false;
|
|
304
341
|
}
|
|
@@ -328,12 +365,7 @@ export class RuntimeOwner {
|
|
|
328
365
|
gitDelta = "unknown";
|
|
329
366
|
}
|
|
330
367
|
}
|
|
331
|
-
const rpcLive = this.#opts.rpc.isLive
|
|
332
|
-
? this.#opts.rpc.isLive()
|
|
333
|
-
: await this.#opts.rpc
|
|
334
|
-
.getState()
|
|
335
|
-
.then(() => true)
|
|
336
|
-
.catch(() => false);
|
|
368
|
+
const rpcLive = this.#opts.rpc.isLive ? this.#opts.rpc.isLive() : rpcState !== null;
|
|
337
369
|
const rpcLastFrameAt = this.#opts.rpc.lastFrameAt ? this.#opts.rpc.lastFrameAt() : null;
|
|
338
370
|
// Sticky semantic signals come from the persisted owner event log -> survive polling gaps.
|
|
339
371
|
const recent = (await readEvents(this.#opts.root, this.#opts.sessionId, 0)).slice(-200);
|
|
@@ -343,6 +375,7 @@ export class RuntimeOwner {
|
|
|
343
375
|
(t): t is string => typeof t === "string",
|
|
344
376
|
);
|
|
345
377
|
const lastActivityAt = stamps.length > 0 ? (stamps.sort().at(-1) ?? state.updatedAt) : state.updatedAt;
|
|
378
|
+
const submitGateReason = this.#submitGateReason(state, rpcState);
|
|
346
379
|
return {
|
|
347
380
|
lifecycle: state.lifecycle,
|
|
348
381
|
ownerLive: true,
|
|
@@ -354,6 +387,8 @@ export class RuntimeOwner {
|
|
|
354
387
|
risk: deleted ? "deleted-worktree" : "normal",
|
|
355
388
|
rpcLive,
|
|
356
389
|
rpcLastFrameAt,
|
|
390
|
+
readyForSubmit: submitGateReason === null,
|
|
391
|
+
submitUnavailableReason: submitGateReason,
|
|
357
392
|
};
|
|
358
393
|
}
|
|
359
394
|
|
|
@@ -543,10 +578,21 @@ export class RuntimeOwner {
|
|
|
543
578
|
const prompt = typeof input.prompt === "string" ? input.prompt : "";
|
|
544
579
|
const state = await this.#loadState();
|
|
545
580
|
if (!prompt) {
|
|
546
|
-
return this.#response(
|
|
581
|
+
return this.#response(
|
|
582
|
+
state,
|
|
583
|
+
{ accepted: false, submitted: false, reason: "empty-prompt" },
|
|
584
|
+
false,
|
|
585
|
+
"empty-prompt",
|
|
586
|
+
);
|
|
547
587
|
}
|
|
548
|
-
|
|
549
|
-
|
|
588
|
+
const lifecycleGate = submitUnavailableReason(state.lifecycle, true);
|
|
589
|
+
if (lifecycleGate) {
|
|
590
|
+
return this.#response(
|
|
591
|
+
state,
|
|
592
|
+
{ accepted: false, submitted: false, reason: lifecycleGate },
|
|
593
|
+
false,
|
|
594
|
+
lifecycleGate,
|
|
595
|
+
);
|
|
550
596
|
}
|
|
551
597
|
const result = await singleFlightAccept(this.#opts.rpc, prompt, this.#opts.acceptanceTimeoutMs);
|
|
552
598
|
if (result.accepted) {
|
|
@@ -560,11 +606,12 @@ export class RuntimeOwner {
|
|
|
560
606
|
} else {
|
|
561
607
|
await this.#emit("warn", "prompt_not_accepted", { reason: result.reason });
|
|
562
608
|
}
|
|
609
|
+
const submitGateReason = result.accepted ? null : result.reason === "pre-state-not-idle" ? "rpc-not-idle" : null;
|
|
563
610
|
return this.#response(
|
|
564
611
|
state,
|
|
565
612
|
{
|
|
566
613
|
accepted: result.accepted,
|
|
567
|
-
submitted:
|
|
614
|
+
submitted: result.commandId !== null,
|
|
568
615
|
reason: result.reason,
|
|
569
616
|
commandId: result.commandId,
|
|
570
617
|
preSubmitCursor: result.preSubmitCursor,
|
|
@@ -572,12 +619,16 @@ export class RuntimeOwner {
|
|
|
572
619
|
acceptanceEvidence: result.preSubmitState,
|
|
573
620
|
},
|
|
574
621
|
result.accepted,
|
|
622
|
+
submitGateReason,
|
|
575
623
|
);
|
|
576
624
|
}
|
|
577
625
|
|
|
578
626
|
async #observe(): Promise<PrimitiveResponse> {
|
|
579
627
|
const state = await this.#loadState();
|
|
580
|
-
|
|
628
|
+
const observation = await this.#observeGit();
|
|
629
|
+
const submitGateReason =
|
|
630
|
+
typeof observation.submitUnavailableReason === "string" ? observation.submitUnavailableReason : null;
|
|
631
|
+
return this.#response(state, { observation, ownerRouted: true }, true, submitGateReason);
|
|
581
632
|
}
|
|
582
633
|
|
|
583
634
|
async #retire(): Promise<PrimitiveResponse> {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { withFileLock } from "../config/file-lock";
|
|
5
|
+
import type { ReceiptEnvelope } from "./receipts";
|
|
6
|
+
|
|
7
|
+
export const RECEIPT_SPOOL_DIR_ENV = "GJC_RECEIPT_SPOOL_DIR";
|
|
8
|
+
export const RECEIPT_SPOOL_FILENAME = "spool.jsonl";
|
|
9
|
+
export const RECEIPT_SPOOL_CURSOR_WIDTH = 12;
|
|
10
|
+
|
|
11
|
+
export interface ReceiptSpoolRecord {
|
|
12
|
+
cursor: string;
|
|
13
|
+
envelope: ReceiptEnvelope<unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ReceiptSpoolAppendResult {
|
|
17
|
+
cursor: string;
|
|
18
|
+
path: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const receiptSpoolDirStorage = new AsyncLocalStorage<string | undefined>();
|
|
22
|
+
const spoolQueues = new Map<string, Promise<void>>();
|
|
23
|
+
const noop = (): void => undefined;
|
|
24
|
+
export async function withReceiptSpoolDir<T>(spoolDir: string, fn: () => Promise<T>): Promise<T> {
|
|
25
|
+
const trimmed = spoolDir.trim();
|
|
26
|
+
if (!trimmed) throw new Error("receipt_spool_dir_empty");
|
|
27
|
+
const resolved = path.resolve(trimmed);
|
|
28
|
+
return receiptSpoolDirStorage.run(resolved, fn);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveReceiptSpoolDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
|
32
|
+
const active = receiptSpoolDirStorage.getStore();
|
|
33
|
+
if (active !== undefined) return active;
|
|
34
|
+
const raw = env[RECEIPT_SPOOL_DIR_ENV]?.trim();
|
|
35
|
+
return raw ? path.resolve(raw) : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function receiptSpoolPath(spoolDir: string): string {
|
|
39
|
+
return path.join(path.resolve(spoolDir), RECEIPT_SPOOL_FILENAME);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseCursor(value: unknown): bigint | undefined {
|
|
43
|
+
if (typeof value !== "string" || !/^\d+$/.test(value)) return undefined;
|
|
44
|
+
try {
|
|
45
|
+
return BigInt(value);
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatReceiptSpoolCursor(cursor: bigint): string {
|
|
52
|
+
const raw = cursor.toString();
|
|
53
|
+
return raw.length >= RECEIPT_SPOOL_CURSOR_WIDTH ? raw : raw.padStart(RECEIPT_SPOOL_CURSOR_WIDTH, "0");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readHighestReceiptSpoolCursor(spoolDir: string): Promise<bigint> {
|
|
57
|
+
const spoolFile = receiptSpoolPath(spoolDir);
|
|
58
|
+
let raw: string;
|
|
59
|
+
try {
|
|
60
|
+
raw = await fs.readFile(spoolFile, "utf8");
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0n;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let highest = 0n;
|
|
67
|
+
for (const line of raw.split("\n")) {
|
|
68
|
+
const trimmed = line.trim();
|
|
69
|
+
if (!trimmed) continue;
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(trimmed) as { cursor?: unknown };
|
|
72
|
+
const cursor = parseCursor(parsed.cursor);
|
|
73
|
+
if (cursor !== undefined && cursor > highest) highest = cursor;
|
|
74
|
+
} catch {
|
|
75
|
+
// A crash may leave a torn tail; consumers skip malformed lines and so do we.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return highest;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function enqueueSpoolAppend<T>(spoolFile: string, task: () => Promise<T>): Promise<T> {
|
|
82
|
+
const previous = spoolQueues.get(spoolFile) ?? Promise.resolve();
|
|
83
|
+
const running = previous.catch(noop).then(task);
|
|
84
|
+
const normalized = running.then(noop, noop);
|
|
85
|
+
spoolQueues.set(spoolFile, normalized);
|
|
86
|
+
normalized
|
|
87
|
+
.finally(() => {
|
|
88
|
+
if (spoolQueues.get(spoolFile) === normalized) spoolQueues.delete(spoolFile);
|
|
89
|
+
})
|
|
90
|
+
.catch(noop);
|
|
91
|
+
return running;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function appendReceiptToSpool(
|
|
95
|
+
spoolDir: string,
|
|
96
|
+
envelope: ReceiptEnvelope<unknown>,
|
|
97
|
+
): Promise<ReceiptSpoolAppendResult> {
|
|
98
|
+
const resolvedDir = path.resolve(spoolDir);
|
|
99
|
+
const spoolFile = receiptSpoolPath(resolvedDir);
|
|
100
|
+
return enqueueSpoolAppend(spoolFile, async () => {
|
|
101
|
+
await fs.mkdir(resolvedDir, { recursive: true, mode: 0o700 });
|
|
102
|
+
return withFileLock(
|
|
103
|
+
spoolFile,
|
|
104
|
+
async () => {
|
|
105
|
+
const cursor = formatReceiptSpoolCursor((await readHighestReceiptSpoolCursor(resolvedDir)) + 1n);
|
|
106
|
+
const record: ReceiptSpoolRecord = { cursor, envelope };
|
|
107
|
+
const handle = await fs.open(spoolFile, "a", 0o600);
|
|
108
|
+
try {
|
|
109
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
|
|
110
|
+
await handle.sync();
|
|
111
|
+
} finally {
|
|
112
|
+
await handle.close();
|
|
113
|
+
}
|
|
114
|
+
return { cursor, path: spoolFile };
|
|
115
|
+
},
|
|
116
|
+
{ staleMs: 30_000, retries: 100, retryDelayMs: 25 },
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function appendReceiptToConfiguredSpool(
|
|
122
|
+
envelope: ReceiptEnvelope<unknown>,
|
|
123
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
124
|
+
): Promise<ReceiptSpoolAppendResult | undefined> {
|
|
125
|
+
const spoolDir = resolveReceiptSpoolDir(env);
|
|
126
|
+
if (!spoolDir) return undefined;
|
|
127
|
+
return appendReceiptToSpool(spoolDir, envelope);
|
|
128
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import type { HarnessLifecycle, NextAllowedAction, PrimitiveResponse, SessionState, SessionStateView } from "./types";
|
|
9
9
|
|
|
10
10
|
const TERMINAL_LIFECYCLES: ReadonlySet<HarnessLifecycle> = new Set(["completed", "retired"]);
|
|
11
|
+
const SUBMIT_READY_LIFECYCLES: ReadonlySet<HarnessLifecycle> = new Set(["started", "observing"]);
|
|
11
12
|
|
|
12
13
|
const TRANSITIONS: Record<HarnessLifecycle, readonly HarnessLifecycle[]> = {
|
|
13
14
|
new: ["started", "blocked", "retired"],
|
|
@@ -37,11 +38,32 @@ export function assertTransition(from: HarnessLifecycle, to: HarnessLifecycle):
|
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
export interface NextAllowedActionsOptions {
|
|
42
|
+
/** Additional live-owner/RPC readiness gate for submit, e.g. rpc-not-idle. */
|
|
43
|
+
submitUnavailableReason?: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function submitUnavailableReason(
|
|
47
|
+
lifecycle: HarnessLifecycle,
|
|
48
|
+
ownerLive: boolean,
|
|
49
|
+
gateReason: string | null = null,
|
|
50
|
+
): string | null {
|
|
51
|
+
if (isTerminal(lifecycle)) return `lifecycle-terminal:${lifecycle}`;
|
|
52
|
+
if (lifecycle === "blocked") return "lifecycle-blocked";
|
|
53
|
+
if (!SUBMIT_READY_LIFECYCLES.has(lifecycle)) return `lifecycle-not-idle:${lifecycle}`;
|
|
54
|
+
if (!ownerLive) return "owner-not-live";
|
|
55
|
+
return gateReason;
|
|
56
|
+
}
|
|
57
|
+
|
|
40
58
|
/**
|
|
41
59
|
* Derive the permitted next actions for a session given its lifecycle and whether
|
|
42
60
|
* a live owner currently holds the lease.
|
|
43
61
|
*/
|
|
44
|
-
export function nextAllowedActions(
|
|
62
|
+
export function nextAllowedActions(
|
|
63
|
+
lifecycle: HarnessLifecycle,
|
|
64
|
+
ownerLive: boolean,
|
|
65
|
+
options: NextAllowedActionsOptions = {},
|
|
66
|
+
): NextAllowedAction[] {
|
|
45
67
|
const terminal = isTerminal(lifecycle);
|
|
46
68
|
const actions: NextAllowedAction[] = [];
|
|
47
69
|
const add = (verb: NextAllowedAction["verb"], available: boolean, reason?: string): void => {
|
|
@@ -57,11 +79,10 @@ export function nextAllowedActions(lifecycle: HarnessLifecycle, ownerLive: boole
|
|
|
57
79
|
// `start` creates a new session; never re-applicable to an existing record.
|
|
58
80
|
add("start", false, "session-already-exists");
|
|
59
81
|
|
|
60
|
-
// `submit` is owner-routed: it requires a live owner
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
else add("submit", true);
|
|
82
|
+
// `submit` is owner-routed: it requires a live owner, a submit-ready lifecycle,
|
|
83
|
+
// and (for owner-observed responses) an idle/routable RPC backend.
|
|
84
|
+
const submitReason = submitUnavailableReason(lifecycle, ownerLive, options.submitUnavailableReason ?? null);
|
|
85
|
+
add("submit", submitReason === null, submitReason ?? undefined);
|
|
65
86
|
|
|
66
87
|
// `recover` handles a dead/failed owner, so it is available without a live owner.
|
|
67
88
|
add("recover", !terminal, terminal ? `lifecycle-terminal:${lifecycle}` : undefined);
|
|
@@ -18,6 +18,8 @@ import * as fsSync from "node:fs";
|
|
|
18
18
|
import * as fs from "node:fs/promises";
|
|
19
19
|
import * as os from "node:os";
|
|
20
20
|
import * as path from "node:path";
|
|
21
|
+
import { appendReceiptToConfiguredSpool } from "./receipt-spool";
|
|
22
|
+
import type { ReceiptEnvelope } from "./receipts";
|
|
21
23
|
import type { EventEnvelope, ReceiptFamily, SessionState } from "./types";
|
|
22
24
|
|
|
23
25
|
interface HarnessRootRegistryEntry {
|
|
@@ -227,6 +229,26 @@ async function readJson<T>(file: string): Promise<T | null> {
|
|
|
227
229
|
throw error;
|
|
228
230
|
}
|
|
229
231
|
}
|
|
232
|
+
function isReceiptEnvelope(value: unknown): value is ReceiptEnvelope<unknown> {
|
|
233
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
234
|
+
const envelope = value as Record<string, unknown>;
|
|
235
|
+
return (
|
|
236
|
+
typeof envelope.receiptId === "string" &&
|
|
237
|
+
typeof envelope.schemaVersion === "number" &&
|
|
238
|
+
typeof envelope.sessionId === "string" &&
|
|
239
|
+
typeof envelope.family === "string" &&
|
|
240
|
+
typeof envelope.valid === "boolean" &&
|
|
241
|
+
typeof envelope.createdAt === "string" &&
|
|
242
|
+
typeof envelope.source === "string" &&
|
|
243
|
+
envelope.subject !== null &&
|
|
244
|
+
typeof envelope.subject === "object" &&
|
|
245
|
+
envelope.evidence !== null &&
|
|
246
|
+
typeof envelope.evidence === "object" &&
|
|
247
|
+
envelope.artifactHashes !== null &&
|
|
248
|
+
typeof envelope.artifactHashes === "object" &&
|
|
249
|
+
typeof envelope.sha256 === "string"
|
|
250
|
+
);
|
|
251
|
+
}
|
|
230
252
|
|
|
231
253
|
export async function readSessionState(root: string, sessionId: string): Promise<SessionState | null> {
|
|
232
254
|
return readJson<SessionState>(sessionPaths(root, sessionId).state);
|
|
@@ -372,6 +394,7 @@ export async function writeReceiptImmutable(
|
|
|
372
394
|
path: file,
|
|
373
395
|
};
|
|
374
396
|
await fs.appendFile(paths.receiptsIndex, `${JSON.stringify(entry)}\n`, "utf8");
|
|
397
|
+
if (isReceiptEnvelope(value)) await appendReceiptToConfiguredSpool(value);
|
|
375
398
|
return entry;
|
|
376
399
|
}
|
|
377
400
|
|
|
@@ -210,6 +210,10 @@ export interface Observation {
|
|
|
210
210
|
rpcLive?: boolean;
|
|
211
211
|
/** ISO timestamp of the most recent RPC frame the owner observed, if any. */
|
|
212
212
|
rpcLastFrameAt?: string | null;
|
|
213
|
+
/** True only when owner/rpc/lifecycle gates indicate a prompt can be submitted now. */
|
|
214
|
+
readyForSubmit?: boolean;
|
|
215
|
+
/** Present when readyForSubmit is false; mirrors submit's nextAllowedActions reason. */
|
|
216
|
+
submitUnavailableReason?: string | null;
|
|
213
217
|
}
|
|
214
218
|
|
|
215
219
|
/** Input to the deterministic recovery classifier. */
|
|
@@ -295,12 +295,12 @@ export function summarizeMentalModel(model: MentalModelSummary): string {
|
|
|
295
295
|
* snapshot only; the diff is computed locally for display purposes.
|
|
296
296
|
*
|
|
297
297
|
* This is intentionally minimal — for "what changed" at a glance, not for a
|
|
298
|
-
* full structural diff. Each side is capped at `MAX_LCS_LINES` lines
|
|
299
|
-
* the
|
|
300
|
-
*
|
|
301
|
-
*
|
|
298
|
+
* full structural diff. Each side is capped at `MAX_LCS_LINES` lines before
|
|
299
|
+
* the Hunt-Szymanski LCS pass so a long curated model can never hang the TUI;
|
|
300
|
+
* output is then capped at `maxLines` so the rendered diff stays readable. The
|
|
301
|
+
* cap is signalled inline.
|
|
302
302
|
*/
|
|
303
|
-
/** Hard cap on input line count per side before LCS. Keeps
|
|
303
|
+
/** Hard cap on input line count per side before LCS. Keeps worst-case repeated-line matching bounded. */
|
|
304
304
|
export const MAX_LCS_LINES = 1_000;
|
|
305
305
|
|
|
306
306
|
export function diffMentalModelContent(previous: string | null, current: string, maxLines = 200): string {
|
|
@@ -346,24 +346,25 @@ export function diffMentalModelContent(previous: string | null, current: string,
|
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
function longestCommonSubsequence(a: string[], b: string[]): string[] {
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
349
|
+
return longestCommonSubsequenceDense(a, b);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function longestCommonSubsequenceDense(a: string[], b: string[]): string[] {
|
|
353
|
+
const table: number[][] = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
|
|
354
|
+
for (let i = 0; i < a.length; i++) {
|
|
355
|
+
for (let j = 0; j < b.length; j++) {
|
|
356
|
+
table[i + 1]![j + 1] = a[i] === b[j] ? table[i]![j]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!);
|
|
356
357
|
}
|
|
357
358
|
}
|
|
358
359
|
const out: string[] = [];
|
|
359
|
-
let i =
|
|
360
|
-
let j =
|
|
360
|
+
let i = a.length;
|
|
361
|
+
let j = b.length;
|
|
361
362
|
while (i > 0 && j > 0) {
|
|
362
363
|
if (a[i - 1] === b[j - 1]) {
|
|
363
|
-
out.push(a[i - 1]);
|
|
364
|
+
out.push(a[i - 1]!);
|
|
364
365
|
i--;
|
|
365
366
|
j--;
|
|
366
|
-
} else if (table[i - 1][j] >= table[i][j - 1]) {
|
|
367
|
+
} else if (table[i - 1]![j]! >= table[i]![j - 1]!) {
|
|
367
368
|
i--;
|
|
368
369
|
} else {
|
|
369
370
|
j--;
|