@akagilnc/pi-workflow-roles 0.1.2157 → 0.1.2173

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.
@@ -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 { lstat, mkdir, open, readFile, readdir, writeFile } from "node:fs/promises";
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
- if (parentEntries[i]?.type === "message" && parentEntries[i]?.message?.role === "user") {
731
- latestParentUserIndex = i;
732
- break;
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
- /** Publish the retained residual with exclusive, complete-write semantics. */
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
- const existing = await lstat(evidencePath);
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
- const handle = await open(evidencePath, "wx", 0o600);
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