@akagilnc/pi-workflow-roles 0.1.2152 → 0.1.2169
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/dist/public-cli/main.js +556 -368
- package/package.json +1 -1
- package/src/public-cli/auto-resume.ts +80 -0
- package/src/public-cli/coder-run.ts +35 -30
- package/src/public-cli/collector-run.ts +1 -1
- package/src/public-cli/doctor-run.ts +1 -1
- package/src/public-cli/fixer-run.ts +32 -31
- package/src/public-cli/judge-run.ts +33 -29
- package/src/public-cli/merger-run.ts +32 -30
- package/src/public-cli/reviewer-run.ts +32 -31
- package/src/public-cli/run-lifecycle.ts +89 -25
- package/src/public-cli/settlement.ts +170 -15
- package/src/public-cli/terminal.ts +8 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Durable Role run lifecycle for public CLI (ADR 0052 / #11 / #108).
|
|
2
|
+
* Durable Role run lifecycle for public CLI (ADR 0052 / #11 / #108 / #416).
|
|
3
3
|
* States: admitted → running → resumable | terminal.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* #416 (owner 2026-08-22): resume no longer gates on terminal/resumable or typed 429 —
|
|
5
|
+
* any existing run with an available Pi session principal may be resumed; caller decides.
|
|
6
|
+
* Prose is never regex-classified as quota evidence.
|
|
6
7
|
*/
|
|
7
|
-
import { lstat, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
8
|
+
import { chmod, lstat, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
8
9
|
import { join } from "node:path";
|
|
9
10
|
|
|
10
11
|
import {
|
|
@@ -38,7 +39,9 @@ import {
|
|
|
38
39
|
type InvocationEffectiveModel,
|
|
39
40
|
} from "./invocation.ts";
|
|
40
41
|
|
|
41
|
-
/** Providers eligible for v1 typed-429 resume (Codex / xAI only).
|
|
42
|
+
/** Providers eligible for v1 typed-429 resume (Codex / xAI only).
|
|
43
|
+
* @deprecated v1 429-only gate removed by #416 (owner 2026-08-22: "根本不要有限制"). Kept for compatibility; do not use for new branching.
|
|
44
|
+
*/
|
|
42
45
|
export const V1_RESUMABLE_PROVIDERS = ["openai-codex", "xai"] as const;
|
|
43
46
|
export type V1ResumableProvider = (typeof V1_RESUMABLE_PROVIDERS)[number];
|
|
44
47
|
|
|
@@ -49,6 +52,9 @@ export type TypedHttp429Observation = {
|
|
|
49
52
|
readonly provider: V1ResumableProvider;
|
|
50
53
|
};
|
|
51
54
|
|
|
55
|
+
/** Per single LLM call auto-resume retries (call-local, no persistence). See #416 scope correction 2026-08-22. */
|
|
56
|
+
export const AUTO_RESUME_LIMIT = 2 as const;
|
|
57
|
+
|
|
52
58
|
export type RoleRunRecord = {
|
|
53
59
|
readonly runId: string;
|
|
54
60
|
readonly role:
|
|
@@ -69,7 +75,9 @@ export type RoleRunRecord = {
|
|
|
69
75
|
readonly admittedRequestPath: string;
|
|
70
76
|
/** Coder/Fixer — preserved for resume continuation. */
|
|
71
77
|
readonly phase?: CoderPhase | FixerPhase;
|
|
72
|
-
/** Present only while state === "resumable".
|
|
78
|
+
/** Present only while state === "resumable".
|
|
79
|
+
* @deprecated retained only for historical 429 runs; #416 no longer gates resume on this field.
|
|
80
|
+
*/
|
|
73
81
|
readonly resumable?: TypedHttp429Observation;
|
|
74
82
|
};
|
|
75
83
|
|
|
@@ -79,12 +87,14 @@ export const RESUME_TRANSPORT_ENVELOPE = "[ak-role:resume-continue]" as const;
|
|
|
79
87
|
const RUN_STATE_FILE = "run-state.json";
|
|
80
88
|
const WRITER_LOCK_FILE = "writer.lock";
|
|
81
89
|
|
|
90
|
+
/** @deprecated #416: 429-only classification removed; kept for compatibility. */
|
|
82
91
|
export function isV1ResumableProvider(
|
|
83
92
|
provider: string,
|
|
84
93
|
): provider is V1ResumableProvider {
|
|
85
94
|
return (V1_RESUMABLE_PROVIDERS as readonly string[]).includes(provider);
|
|
86
95
|
}
|
|
87
96
|
|
|
97
|
+
/** @deprecated #416: 429-only observation no longer gates resume; kept for historical runs. */
|
|
88
98
|
export async function readTypedHttp429Observation(
|
|
89
99
|
runDirectory: string,
|
|
90
100
|
): Promise<TypedHttp429Observation | undefined> {
|
|
@@ -96,6 +106,7 @@ export async function readTypedHttp429Observation(
|
|
|
96
106
|
}
|
|
97
107
|
|
|
98
108
|
/**
|
|
109
|
+
* @deprecated #416: 429-only resumability predicate removed from gating. Kept for compatibility.
|
|
99
110
|
* True only when a typed HTTP 429 was observed and no lawful terminal exists.
|
|
100
111
|
* Callers must pass the lawful-terminal fact from settlement, not re-infer it.
|
|
101
112
|
*/
|
|
@@ -266,6 +277,7 @@ export async function markRunRunning(
|
|
|
266
277
|
});
|
|
267
278
|
}
|
|
268
279
|
|
|
280
|
+
/** @deprecated #416: 429-only resumable marker; kept for historical runs. */
|
|
269
281
|
export async function markRunResumable(
|
|
270
282
|
runDirectory: string,
|
|
271
283
|
observation: TypedHttp429Observation,
|
|
@@ -328,13 +340,52 @@ export type RunWriterLease = {
|
|
|
328
340
|
release(): Promise<void>;
|
|
329
341
|
};
|
|
330
342
|
|
|
343
|
+
/**
|
|
344
|
+
* True error identity for diagnostics — name/code/message as-is, never a
|
|
345
|
+
* guessed label (failure-honesty constitution).
|
|
346
|
+
*/
|
|
347
|
+
function describeErrorIdentity(error: unknown): string {
|
|
348
|
+
const candidate = error as { name?: unknown; code?: unknown; message?: unknown };
|
|
349
|
+
const name =
|
|
350
|
+
typeof candidate?.name === "string" && candidate.name !== ""
|
|
351
|
+
? candidate.name
|
|
352
|
+
: typeof error;
|
|
353
|
+
const code =
|
|
354
|
+
typeof candidate?.code === "string" || typeof candidate?.code === "number"
|
|
355
|
+
? ` code=${String(candidate.code)}`
|
|
356
|
+
: "";
|
|
357
|
+
const message =
|
|
358
|
+
typeof candidate?.message === "string" && candidate.message !== ""
|
|
359
|
+
? `: ${candidate.message}`
|
|
360
|
+
: "";
|
|
361
|
+
return `${name}${code}${message}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
331
364
|
/**
|
|
332
365
|
* Acquire the one-writer lease for a Role run. Concurrent acquire rejects
|
|
333
366
|
* without dispatch. Exclusive create — no second writer.
|
|
367
|
+
*
|
|
368
|
+
* `onCleanupFailure` receives a non-terminal diagnostic line when release-time
|
|
369
|
+
* lock cleanup fails. Release stays best-effort (a stale lock resurfaces as
|
|
370
|
+
* RunWriterLeaseHeldError on next acquire), but the true error identity must
|
|
371
|
+
* still land somewhere observable — silent swallowing is forbidden.
|
|
334
372
|
*/
|
|
335
373
|
export async function acquireRunWriterLease(
|
|
336
374
|
runDirectory: string,
|
|
375
|
+
onCleanupFailure?: (diagnostic: string) => void,
|
|
337
376
|
): Promise<RunWriterLease> {
|
|
377
|
+
const reportCleanupFailure = (error: unknown): void => {
|
|
378
|
+
// Sink isolation: a throwing onCleanupFailure must not propagate through
|
|
379
|
+
// release() — release stays best-effort by contract. The true cleanup
|
|
380
|
+
// cause has already been handed to the sink as its argument.
|
|
381
|
+
try {
|
|
382
|
+
onCleanupFailure?.(
|
|
383
|
+
`writer lease lock cleanup failed (best-effort continue; stale lock resurfaces as lease-held on next acquire) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`,
|
|
384
|
+
);
|
|
385
|
+
} catch {
|
|
386
|
+
// diagnostic-sink failure is itself best-effort; never break release().
|
|
387
|
+
}
|
|
388
|
+
};
|
|
338
389
|
const lockPath = join(runDirectory, WRITER_LOCK_FILE);
|
|
339
390
|
try {
|
|
340
391
|
const handle = await open(lockPath, "wx");
|
|
@@ -352,7 +403,23 @@ export async function acquireRunWriterLease(
|
|
|
352
403
|
if (released) return;
|
|
353
404
|
released = true;
|
|
354
405
|
await handle.close().catch(() => undefined);
|
|
355
|
-
|
|
406
|
+
try {
|
|
407
|
+
await unlink(lockPath);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if ((error as { code?: unknown }).code === "EACCES") {
|
|
410
|
+
try {
|
|
411
|
+
await chmod(runDirectory, 0o755);
|
|
412
|
+
await unlink(lockPath);
|
|
413
|
+
} catch (retryError) {
|
|
414
|
+
// best-effort cleanup: stale lock will surface as lease-held on next acquire (exit 2),
|
|
415
|
+
// but the true chmod/unlink cause must be recorded, not swallowed.
|
|
416
|
+
reportCleanupFailure(retryError);
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
// non-EACCES unlink failure is best-effort settlement cleanup; record true cause.
|
|
420
|
+
reportCleanupFailure(error);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
356
423
|
},
|
|
357
424
|
};
|
|
358
425
|
} catch (error) {
|
|
@@ -453,7 +520,7 @@ async function loadResumableRunRecord(
|
|
|
453
520
|
runId: string,
|
|
454
521
|
): Promise<{
|
|
455
522
|
readonly run: RoleRunRecord;
|
|
456
|
-
readonly observation
|
|
523
|
+
readonly observation?: TypedHttp429Observation;
|
|
457
524
|
readonly admittedFields: LoadedAdmittedRequestFields;
|
|
458
525
|
}> {
|
|
459
526
|
const runDirectory = await findRunDirectoryById(home, runId);
|
|
@@ -464,12 +531,8 @@ async function loadResumableRunRecord(
|
|
|
464
531
|
if (run === undefined) {
|
|
465
532
|
throw new CliUsageError(`unknown role run id: ${runId}`);
|
|
466
533
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
if (run.state !== "resumable" || run.resumable === undefined) {
|
|
471
|
-
throw new CliUsageError(`role run is not resumable: ${runId}`);
|
|
472
|
-
}
|
|
534
|
+
// #416: removed terminal/resumable gates per owner decision "根本不要有限制" (2026-08-22).
|
|
535
|
+
// Only the exact Pi session principal check remains as honest failure.
|
|
473
536
|
// Exact Pi session principal must be present before resume dispatches.
|
|
474
537
|
if (!(await isSessionPrincipalAvailable(run.sessionFile))) {
|
|
475
538
|
throw new CliUsageError(
|
|
@@ -618,7 +681,7 @@ async function loadResumableRunRecord(
|
|
|
618
681
|
}
|
|
619
682
|
return {
|
|
620
683
|
run,
|
|
621
|
-
observation: run.resumable,
|
|
684
|
+
...(run.resumable === undefined ? {} : { observation: run.resumable }),
|
|
622
685
|
admittedFields: {
|
|
623
686
|
instruction,
|
|
624
687
|
instructionEmpty,
|
|
@@ -641,25 +704,26 @@ async function loadResumableRunRecord(
|
|
|
641
704
|
export type LoadedResumableJudgeRun = {
|
|
642
705
|
readonly admitted: AdmittedJudgeInvocation;
|
|
643
706
|
readonly run: RoleRunRecord;
|
|
644
|
-
|
|
707
|
+
/** @deprecated #416: resumable observation no longer gates resume; may be undefined. */
|
|
708
|
+
readonly observation?: TypedHttp429Observation;
|
|
645
709
|
};
|
|
646
710
|
|
|
647
711
|
export type LoadedResumableCoderRun = {
|
|
648
712
|
readonly admitted: AdmittedCoderInvocation;
|
|
649
713
|
readonly run: RoleRunRecord;
|
|
650
|
-
readonly observation
|
|
714
|
+
readonly observation?: TypedHttp429Observation;
|
|
651
715
|
};
|
|
652
716
|
|
|
653
717
|
export type LoadedResumableFixerRun = {
|
|
654
718
|
readonly admitted: AdmittedFixerInvocation;
|
|
655
719
|
readonly run: RoleRunRecord;
|
|
656
|
-
readonly observation
|
|
720
|
+
readonly observation?: TypedHttp429Observation;
|
|
657
721
|
};
|
|
658
722
|
|
|
659
723
|
export type LoadedResumableReviewerRun = {
|
|
660
724
|
readonly admitted: AdmittedReviewerInvocation;
|
|
661
725
|
readonly run: RoleRunRecord;
|
|
662
|
-
readonly observation
|
|
726
|
+
readonly observation?: TypedHttp429Observation;
|
|
663
727
|
};
|
|
664
728
|
|
|
665
729
|
/**
|
|
@@ -693,7 +757,7 @@ export async function loadResumableJudgeRun(
|
|
|
693
757
|
return {
|
|
694
758
|
admitted,
|
|
695
759
|
run: loaded.run,
|
|
696
|
-
observation: loaded.observation,
|
|
760
|
+
...(loaded.observation === undefined ? {} : { observation: loaded.observation }),
|
|
697
761
|
};
|
|
698
762
|
}
|
|
699
763
|
|
|
@@ -747,7 +811,7 @@ export async function loadResumableCoderRun(
|
|
|
747
811
|
return {
|
|
748
812
|
admitted,
|
|
749
813
|
run: loaded.run,
|
|
750
|
-
observation: loaded.observation,
|
|
814
|
+
...(loaded.observation === undefined ? {} : { observation: loaded.observation }),
|
|
751
815
|
};
|
|
752
816
|
}
|
|
753
817
|
|
|
@@ -806,7 +870,7 @@ export async function loadResumableFixerRun(
|
|
|
806
870
|
return {
|
|
807
871
|
admitted,
|
|
808
872
|
run: loaded.run,
|
|
809
|
-
observation: loaded.observation,
|
|
873
|
+
...(loaded.observation === undefined ? {} : { observation: loaded.observation }),
|
|
810
874
|
};
|
|
811
875
|
}
|
|
812
876
|
|
|
@@ -853,14 +917,14 @@ export async function loadResumableReviewerRun(
|
|
|
853
917
|
return {
|
|
854
918
|
admitted,
|
|
855
919
|
run: loaded.run,
|
|
856
|
-
observation: loaded.observation,
|
|
920
|
+
...(loaded.observation === undefined ? {} : { observation: loaded.observation }),
|
|
857
921
|
};
|
|
858
922
|
}
|
|
859
923
|
|
|
860
924
|
export type LoadedResumableMergerRun = {
|
|
861
925
|
readonly admitted: AdmittedMergerInvocation;
|
|
862
926
|
readonly run: RoleRunRecord;
|
|
863
|
-
readonly observation
|
|
927
|
+
readonly observation?: TypedHttp429Observation;
|
|
864
928
|
};
|
|
865
929
|
|
|
866
930
|
/**
|
|
@@ -913,7 +977,7 @@ export async function loadResumableMergerRun(
|
|
|
913
977
|
return {
|
|
914
978
|
admitted,
|
|
915
979
|
run: loaded.run,
|
|
916
|
-
observation: loaded.observation,
|
|
980
|
+
...(loaded.observation === undefined ? {} : { observation: loaded.observation }),
|
|
917
981
|
};
|
|
918
982
|
}
|
|
919
983
|
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Controlled failures and audit human decisions settle here without washing causes.
|
|
5
5
|
*/
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
-
import {
|
|
7
|
+
import { constants as fsConstants } from "node:fs";
|
|
8
|
+
import { appendFile, lstat, mkdir, open, readFile, readdir, writeFile } from "node:fs/promises";
|
|
8
9
|
import { dirname, join } from "node:path";
|
|
9
10
|
|
|
10
11
|
import { isAuditEscalationResult } from "../audit-escalation.ts";
|
|
@@ -14,6 +15,7 @@ import { JUDGE_AUDIT_TOOL_NAME } from "../judge-auditor.ts";
|
|
|
14
15
|
import { REVIEWER_AUDIT_TOOL_NAME } from "../reviewer-auditor.ts";
|
|
15
16
|
import { knownFailureFromProviderStop, type ExplicitInternalKnownFailure, readReviewerDispatchRejection } from "./explicit-internal.ts";
|
|
16
17
|
import {
|
|
18
|
+
RESUME_TRANSPORT_ENVELOPE,
|
|
17
19
|
isV1ResumableProvider,
|
|
18
20
|
readLatestTypedProviderHttpObservation,
|
|
19
21
|
readTypedHttp429Observation,
|
|
@@ -725,12 +727,24 @@ export async function readBoundAuditorKnownFailure(
|
|
|
725
727
|
}
|
|
726
728
|
const parentId = parentEntries.find((entry) => entry.type === "session")?.id;
|
|
727
729
|
if (parentId === undefined) return undefined;
|
|
730
|
+
const RESUME_ENVELOPE = RESUME_TRANSPORT_ENVELOPE;
|
|
731
|
+
const isResumeEnvelope = (msg: unknown): boolean => {
|
|
732
|
+
if (!isRecord(msg) || msg.role !== "user") return false;
|
|
733
|
+
const text = typeof msg.text === "string" ? msg.text : typeof (msg as { content?: unknown }).content === "string" ? (msg as { content: string }).content : undefined;
|
|
734
|
+
if (text === RESUME_ENVELOPE) return true;
|
|
735
|
+
const content = (msg as { content?: unknown }).content;
|
|
736
|
+
if (Array.isArray(content)) {
|
|
737
|
+
return content.some((p) => isRecord(p) && (p.text === RESUME_ENVELOPE || p.content === RESUME_ENVELOPE));
|
|
738
|
+
}
|
|
739
|
+
return false;
|
|
740
|
+
};
|
|
728
741
|
let latestParentUserIndex = -1;
|
|
729
742
|
for (let i = parentEntries.length - 1; i >= 0; i -= 1) {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
743
|
+
const entry = parentEntries[i];
|
|
744
|
+
if (entry?.type !== "message" || entry.message?.role !== "user") continue;
|
|
745
|
+
if (isResumeEnvelope(entry.message)) continue;
|
|
746
|
+
latestParentUserIndex = i;
|
|
747
|
+
break;
|
|
734
748
|
}
|
|
735
749
|
const childDirectory = join(dirname(sessionFile), "auditor-roles");
|
|
736
750
|
let names: string[];
|
|
@@ -740,6 +754,12 @@ export async function readBoundAuditorKnownFailure(
|
|
|
740
754
|
if (isMissingPathError(error)) return undefined;
|
|
741
755
|
throw sessionReadFailure(error, "failed to read bound auditor session directory");
|
|
742
756
|
}
|
|
757
|
+
// Auto-resume seam (owner A): stale check must ignore resume envelope and
|
|
758
|
+
// prioritize retention. Previous `attemptEntryIndex < latest` discarded the
|
|
759
|
+
// first attempt's child after resume advanced latest, losing retentionFailure
|
|
760
|
+
// when retry had no compliance entry. Fix: ignore envelope for staleness and
|
|
761
|
+
// prefer any valid compliance failure before falling back to primary.
|
|
762
|
+
const validAuditorFiles: Array<{ file: string; entries: SessionEntry[]; attemptEntryId?: string }> = [];
|
|
743
763
|
for (const file of names.filter((name) => name.endsWith(".jsonl")).sort().reverse()) {
|
|
744
764
|
let entries: SessionEntry[];
|
|
745
765
|
try {
|
|
@@ -754,7 +774,10 @@ export async function readBoundAuditorKnownFailure(
|
|
|
754
774
|
const attemptEntryId = typeof bindingParent?.attemptEntryId === "string" ? bindingParent.attemptEntryId : undefined;
|
|
755
775
|
const attemptEntryIndex = attemptEntryId === undefined ? -1 : parentEntries.findIndex((entry) => entry.id === attemptEntryId);
|
|
756
776
|
if (bindingParent?.sessionId !== parentId || bindingParent.sessionFile !== sessionFile || attemptEntryIndex < latestParentUserIndex) continue;
|
|
757
|
-
|
|
777
|
+
validAuditorFiles.push({ file, entries, ...(attemptEntryId === undefined ? {} : { attemptEntryId }) });
|
|
778
|
+
}
|
|
779
|
+
// Prefer compliance failure (retention) from any valid attempt, newest first.
|
|
780
|
+
for (const { entries, attemptEntryId } of validAuditorFiles) {
|
|
758
781
|
const stop = extractSessionProviderStop(entries);
|
|
759
782
|
if (stop === undefined) continue;
|
|
760
783
|
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
@@ -774,6 +797,11 @@ export async function readBoundAuditorKnownFailure(
|
|
|
774
797
|
...(isRecord(failure.details) ? { details: failure.details } : {}),
|
|
775
798
|
};
|
|
776
799
|
}
|
|
800
|
+
}
|
|
801
|
+
// No compliance failure: fall back to most recent provider stop (auto-resume latest attempt).
|
|
802
|
+
for (const { entries } of validAuditorFiles) {
|
|
803
|
+
const stop = extractSessionProviderStop(entries);
|
|
804
|
+
if (stop === undefined) continue;
|
|
777
805
|
const primary = knownFailureFromProviderStop(stop)!;
|
|
778
806
|
return {
|
|
779
807
|
...primary,
|
|
@@ -1852,26 +1880,131 @@ async function ensureAuditEvidenceDirectory(runDirectory: string): Promise<strin
|
|
|
1852
1880
|
return artifactsDir;
|
|
1853
1881
|
}
|
|
1854
1882
|
|
|
1855
|
-
/**
|
|
1883
|
+
/**
|
|
1884
|
+
* #419 per-attempt process history. 史必追加,指针可覆盖;指针可以覆盖的前提是史已落。
|
|
1885
|
+
* Reuses the run session principal's append-only JSONL custom-entry shape
|
|
1886
|
+
* (plain custom entries are state records and never enter LLM context), so no
|
|
1887
|
+
* second ledger mechanism is introduced.
|
|
1888
|
+
*/
|
|
1889
|
+
export const ATTEMPT_HISTORY_ENTRY_TYPE = "ak_run_attempt_history" as const;
|
|
1890
|
+
|
|
1891
|
+
/** Complete per-attempt result as recorded in the appended history. */
|
|
1892
|
+
type AttemptHistoryOutcome =
|
|
1893
|
+
| TerminalRoleOutcome
|
|
1894
|
+
| ({ kind: "failure"; role: string } & ControlledFailure);
|
|
1895
|
+
|
|
1896
|
+
type AttemptHistorySource = {
|
|
1897
|
+
readonly role: string;
|
|
1898
|
+
readonly runId: string;
|
|
1899
|
+
readonly sessionFile: string;
|
|
1900
|
+
};
|
|
1901
|
+
|
|
1902
|
+
/**
|
|
1903
|
+
* Append one attempt's complete result to the run's session principal.
|
|
1904
|
+
* Append failure throws — callers must not overwrite a pointer artifact when
|
|
1905
|
+
* the history entry backing the overwrite did not land (fail closed).
|
|
1906
|
+
*/
|
|
1907
|
+
export async function appendRunAttemptHistory(
|
|
1908
|
+
admitted: AttemptHistorySource,
|
|
1909
|
+
outcome: AttemptHistoryOutcome,
|
|
1910
|
+
): Promise<void> {
|
|
1911
|
+
const entries = await readBoundSessionEntries(admitted.sessionFile);
|
|
1912
|
+
let parentId: string | null = null;
|
|
1913
|
+
let priorEntries = 0;
|
|
1914
|
+
for (const entry of entries) {
|
|
1915
|
+
if (typeof entry.id === "string" && entry.type !== "session") parentId = entry.id;
|
|
1916
|
+
if (
|
|
1917
|
+
entry.type === "custom" &&
|
|
1918
|
+
entry.customType === ATTEMPT_HISTORY_ENTRY_TYPE
|
|
1919
|
+
) {
|
|
1920
|
+
priorEntries += 1;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
const timestamp = new Date().toISOString();
|
|
1924
|
+
// Shape mirrors pi SessionManager.appendCustomEntry.
|
|
1925
|
+
const line = `${JSON.stringify({
|
|
1926
|
+
type: "custom",
|
|
1927
|
+
customType: ATTEMPT_HISTORY_ENTRY_TYPE,
|
|
1928
|
+
data: {
|
|
1929
|
+
sequence: priorEntries + 1,
|
|
1930
|
+
role: admitted.role,
|
|
1931
|
+
runId: admitted.runId,
|
|
1932
|
+
recordedAt: timestamp,
|
|
1933
|
+
outcome,
|
|
1934
|
+
},
|
|
1935
|
+
id: randomUUID(),
|
|
1936
|
+
parentId,
|
|
1937
|
+
timestamp,
|
|
1938
|
+
})}\n`;
|
|
1939
|
+
await appendFile(admitted.sessionFile, line, "utf8");
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/**
|
|
1943
|
+
* Publish the retained residual with complete-write semantics (#419: the
|
|
1944
|
+
* previous attempt's pointer file is a rebuildable view once this attempt's
|
|
1945
|
+
* complete result is in the appended history; planted symlinks/directories
|
|
1946
|
+
* still fail loudly with their #182-A identities).
|
|
1947
|
+
*/
|
|
1856
1948
|
export async function publishComplianceAuditIncompleteEvidence(
|
|
1857
1949
|
admitted: AdmittedRoleInvocation,
|
|
1858
1950
|
outcome: ReturnType<typeof buildAuditIncompleteTerminalOutcome>,
|
|
1859
1951
|
): Promise<TerminalArtifactRef> {
|
|
1952
|
+
await appendRunAttemptHistory(admitted, outcome);
|
|
1953
|
+
if (
|
|
1954
|
+
typeof fsConstants.O_NOFOLLOW !== "number" ||
|
|
1955
|
+
typeof fsConstants.O_NONBLOCK !== "number"
|
|
1956
|
+
) {
|
|
1957
|
+
// #418 fail-closed: on platforms without O_NOFOLLOW (e.g. Windows,
|
|
1958
|
+
// nodejs/node#41590) the JS bitwise-or below would silently drop the flag
|
|
1959
|
+
// and the #182-A anti-symlink protection would vanish. Refuse loudly via
|
|
1960
|
+
// the existing publication-failure channel instead of publishing
|
|
1961
|
+
// unprotected; the complete attempt result above stays in appended history.
|
|
1962
|
+
throw auditArtifactPublicationError(
|
|
1963
|
+
"audit evidence publication requires O_NOFOLLOW|O_NONBLOCK open-flag support (anti-symlink/anti-planted protection must not be silently dropped); refusing to publish",
|
|
1964
|
+
"ENOSYS",
|
|
1965
|
+
);
|
|
1966
|
+
}
|
|
1860
1967
|
const artifactsDir = await ensureAuditEvidenceDirectory(admitted.runDirectory);
|
|
1861
1968
|
const evidencePath = join(artifactsDir, "audit-incomplete.json");
|
|
1969
|
+
let existing: Awaited<ReturnType<typeof lstat>> | undefined;
|
|
1862
1970
|
try {
|
|
1863
|
-
|
|
1864
|
-
throw auditArtifactPublicationError(
|
|
1865
|
-
existing.isSymbolicLink()
|
|
1866
|
-
? "audit evidence destination is a symlink"
|
|
1867
|
-
: "audit evidence destination collision",
|
|
1868
|
-
existing.isSymbolicLink() ? "ELOOP" : "EEXIST",
|
|
1869
|
-
);
|
|
1971
|
+
existing = await lstat(evidencePath);
|
|
1870
1972
|
} catch (error) {
|
|
1871
1973
|
if (!isMissingPathError(error)) throw error;
|
|
1872
1974
|
}
|
|
1873
|
-
|
|
1975
|
+
if (existing?.isSymbolicLink()) {
|
|
1976
|
+
throw auditArtifactPublicationError(
|
|
1977
|
+
"audit evidence destination is a symlink",
|
|
1978
|
+
"ELOOP",
|
|
1979
|
+
);
|
|
1980
|
+
}
|
|
1981
|
+
if (existing && !existing.isFile()) {
|
|
1982
|
+
throw auditArtifactPublicationError(
|
|
1983
|
+
"audit evidence destination is not a regular file",
|
|
1984
|
+
"EEXIST",
|
|
1985
|
+
);
|
|
1986
|
+
}
|
|
1987
|
+
// #182-A protection restored atomically at this open seam: O_NOFOLLOW makes
|
|
1988
|
+
// the open itself refuse symlinks (ELOOP), so one swapped in after the lstat
|
|
1989
|
+
// above can never be followed or written through; O_NONBLOCK keeps a
|
|
1990
|
+
// race-planted FIFO from blocking this open forever. The fstat below
|
|
1991
|
+
// backstops any non-regular object that wins the window.
|
|
1992
|
+
const handle = await open(
|
|
1993
|
+
evidencePath,
|
|
1994
|
+
fsConstants.O_WRONLY |
|
|
1995
|
+
fsConstants.O_CREAT |
|
|
1996
|
+
fsConstants.O_TRUNC |
|
|
1997
|
+
fsConstants.O_NOFOLLOW |
|
|
1998
|
+
fsConstants.O_NONBLOCK,
|
|
1999
|
+
0o600,
|
|
2000
|
+
);
|
|
1874
2001
|
try {
|
|
2002
|
+
if (!(await handle.stat()).isFile()) {
|
|
2003
|
+
throw auditArtifactPublicationError(
|
|
2004
|
+
"audit evidence destination is not a regular file",
|
|
2005
|
+
"EEXIST",
|
|
2006
|
+
);
|
|
2007
|
+
}
|
|
1875
2008
|
await handle.writeFile(`${JSON.stringify(outcome, null, 2)}\n`, "utf8");
|
|
1876
2009
|
await handle.sync();
|
|
1877
2010
|
} finally {
|
|
@@ -2213,6 +2346,9 @@ export async function publishJudgeArtifacts(
|
|
|
2213
2346
|
roleOutcome: TerminalRoleOutcome,
|
|
2214
2347
|
sessionDirectory: string,
|
|
2215
2348
|
): Promise<TerminalArtifactRef[]> {
|
|
2349
|
+
// #419: history first — report/evidence stay last-write-wins views only
|
|
2350
|
+
// because every attempt's complete result has already been appended.
|
|
2351
|
+
await appendRunAttemptHistory(admitted, roleOutcome);
|
|
2216
2352
|
const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
|
|
2217
2353
|
const reportPath = join(artifactsDir, "report.json");
|
|
2218
2354
|
const evidencePath = join(artifactsDir, "evidence.json");
|
|
@@ -2268,6 +2404,7 @@ export async function publishCoderArtifacts(
|
|
|
2268
2404
|
readonly coderOutput?: CoderOutput;
|
|
2269
2405
|
} = {},
|
|
2270
2406
|
): Promise<TerminalArtifactRef[]> {
|
|
2407
|
+
await appendRunAttemptHistory(admitted, roleOutcome);
|
|
2271
2408
|
const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
|
|
2272
2409
|
const reportPath = join(artifactsDir, "report.json");
|
|
2273
2410
|
const evidencePath = join(artifactsDir, "evidence.json");
|
|
@@ -2573,6 +2710,7 @@ export async function publishFixerArtifacts(
|
|
|
2573
2710
|
readonly fixerOutput?: FixerOutput;
|
|
2574
2711
|
},
|
|
2575
2712
|
): Promise<TerminalArtifactRef[]> {
|
|
2713
|
+
await appendRunAttemptHistory(admitted, roleOutcome);
|
|
2576
2714
|
const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
|
|
2577
2715
|
const reportPath = join(artifactsDir, "report.json");
|
|
2578
2716
|
const evidencePath = join(artifactsDir, "evidence.json");
|
|
@@ -2732,6 +2870,7 @@ export async function publishCollectorArtifacts(
|
|
|
2732
2870
|
readonly collectorReceipt?: CollectorReceipt;
|
|
2733
2871
|
} = {},
|
|
2734
2872
|
): Promise<TerminalArtifactRef[]> {
|
|
2873
|
+
await appendRunAttemptHistory(admitted, roleOutcome);
|
|
2735
2874
|
const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
|
|
2736
2875
|
const reportPath = join(artifactsDir, "report.json");
|
|
2737
2876
|
const evidencePath = join(artifactsDir, "evidence.json");
|
|
@@ -2891,6 +3030,7 @@ export async function publishDoctorArtifacts(
|
|
|
2891
3030
|
readonly doctorOutput?: DoctorOutput;
|
|
2892
3031
|
} = {},
|
|
2893
3032
|
): Promise<TerminalArtifactRef[]> {
|
|
3033
|
+
await appendRunAttemptHistory(admitted, roleOutcome);
|
|
2894
3034
|
const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
|
|
2895
3035
|
const reportPath = join(artifactsDir, "report.json");
|
|
2896
3036
|
const evidencePath = join(artifactsDir, "evidence.json");
|
|
@@ -3155,6 +3295,7 @@ export async function publishReviewerArtifacts(
|
|
|
3155
3295
|
readonly reviewerReceipt?: RuntimeReviewerReceiptV2;
|
|
3156
3296
|
},
|
|
3157
3297
|
): Promise<TerminalArtifactRef[]> {
|
|
3298
|
+
await appendRunAttemptHistory(admitted, roleOutcome);
|
|
3158
3299
|
const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
|
|
3159
3300
|
const reportPath = join(artifactsDir, "report.json");
|
|
3160
3301
|
const evidencePath = join(artifactsDir, "evidence.json");
|
|
@@ -3412,6 +3553,7 @@ export async function publishMergerArtifacts(
|
|
|
3412
3553
|
readonly mergerOutput?: MergerOutput;
|
|
3413
3554
|
},
|
|
3414
3555
|
): Promise<TerminalArtifactRef[]> {
|
|
3556
|
+
await appendRunAttemptHistory(admitted, roleOutcome);
|
|
3415
3557
|
const artifactsDir = await ensureRunArtifactsDir(admitted.runDirectory);
|
|
3416
3558
|
const reportPath = join(artifactsDir, "report.json");
|
|
3417
3559
|
const evidencePath = join(artifactsDir, "evidence.json");
|
|
@@ -3746,6 +3888,19 @@ export async function publishFailureArtifacts(
|
|
|
3746
3888
|
);
|
|
3747
3889
|
const priorIssues: PublicationAttempt[] =
|
|
3748
3890
|
baseAttempt === undefined ? [] : [baseAttempt];
|
|
3891
|
+
// #419: each attempt's complete failure result joins the appended history
|
|
3892
|
+
// before any fixed-name artifact view is rewritten. History failure must not
|
|
3893
|
+
// strand the original controlled failure outside settlement — it rides
|
|
3894
|
+
// publicationIssues instead of aborting durability.
|
|
3895
|
+
try {
|
|
3896
|
+
await appendRunAttemptHistory(admitted, {
|
|
3897
|
+
kind: "failure",
|
|
3898
|
+
role: admitted.role,
|
|
3899
|
+
...failure,
|
|
3900
|
+
});
|
|
3901
|
+
} catch (error) {
|
|
3902
|
+
priorIssues.push(publicationAttemptFromError(admitted.sessionFile, error));
|
|
3903
|
+
}
|
|
3749
3904
|
|
|
3750
3905
|
// Prefer conventional names; unique fallback dirs keep colliding fixed paths
|
|
3751
3906
|
// from stranding the original failure outside settlement. Include the ledger
|
|
@@ -231,11 +231,16 @@ export function redactExactRunId(text: string, runId: string): string {
|
|
|
231
231
|
* One admitted Role run's typed Terminal aggregate.
|
|
232
232
|
* Resumable failures carry `resume` and must not re-disclose the run ID via
|
|
233
233
|
* top-level `runId` or public artifact path components — only `resume.command`.
|
|
234
|
+
* #416: autoResumeCount (0..2) is a call-local observation of how many in-place
|
|
235
|
+
* auto-resumes occurred during this single LLM call; it is not persisted to
|
|
236
|
+
* run-state.json and does not participate in limit decisions.
|
|
234
237
|
*/
|
|
235
238
|
export type TerminalResult = {
|
|
236
239
|
roleOutcome: TerminalRoleOutcome;
|
|
237
240
|
navigator: TerminalNavigatorFact;
|
|
238
241
|
artifacts: readonly TerminalArtifactRef[];
|
|
242
|
+
/** Call-local auto-resume observation (0..2) for this single LLM call; read-only, not persisted. */
|
|
243
|
+
autoResumeCount?: number;
|
|
239
244
|
} & (
|
|
240
245
|
| {
|
|
241
246
|
/** Resumable failure: run ID appears only inside resume.command. */
|
|
@@ -330,5 +335,8 @@ export function formatTerminalResult(result: TerminalResult): string {
|
|
|
330
335
|
} else if (result.runId !== undefined) {
|
|
331
336
|
lines.push(`run\t${encodeTerminalField(result.runId)}`);
|
|
332
337
|
}
|
|
338
|
+
if (result.autoResumeCount !== undefined) {
|
|
339
|
+
lines.push(`autoResumeCount\t${encodeTerminalField(String(result.autoResumeCount))}`);
|
|
340
|
+
}
|
|
333
341
|
return `${lines.join("\n")}\n`;
|
|
334
342
|
}
|