pi-oracle 0.2.0 → 0.2.2
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 +19 -0
- package/README.md +3 -1
- package/docs/ORACLE_DESIGN.md +11 -1
- package/extensions/oracle/lib/commands.ts +5 -1
- package/extensions/oracle/lib/jobs.ts +86 -15
- package/extensions/oracle/lib/poller.ts +3 -2
- package/extensions/oracle/lib/runtime.ts +1 -1
- package/extensions/oracle/lib/tools.ts +62 -23
- package/extensions/oracle/worker/artifact-heuristics.d.mts +19 -3
- package/extensions/oracle/worker/artifact-heuristics.mjs +89 -18
- package/extensions/oracle/worker/run-job.mjs +116 -41
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.2 - 2026-04-07
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- missed ChatGPT file artifacts now map generic download controls onto nearby filenames and download from live DOM selectors instead of relying only on filename-labeled snapshot refs
|
|
7
|
+
- oracle jobs no longer report a false-clean completion when response-local artifact signals are present but capture fails or remains inconclusive
|
|
8
|
+
- artifact label extraction now collapses paths and mixed response text down to real filenames so suspicious artifact fallback logic does not emit bogus labels
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- regression coverage for artifact label extraction edge cases and ambiguous download-control artifact detection
|
|
12
|
+
|
|
13
|
+
## 0.2.1 - 2026-04-07
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- wake-up guidance now tells receivers to use `oracle_read(jobId)` as the canonical way to consume completion results
|
|
17
|
+
- manual inspection before the first wake-up no longer suppresses the initial reminder attempt
|
|
18
|
+
- wake-up settlement now records provenance so suppressed/settled delivery can be explained in postmortems
|
|
19
|
+
- queued archive cleanup retries now retry queued archive deletion and keep warnings until cleanup succeeds
|
|
20
|
+
- queued archive byte-pressure accounting now includes retained pre-submit archives instead of only currently queued jobs
|
|
21
|
+
|
|
3
22
|
## 0.2.0 - 2026-04-06
|
|
4
23
|
|
|
5
24
|
### Added
|
package/README.md
CHANGED
|
@@ -136,7 +136,9 @@ Detailed design and maintainer docs:
|
|
|
136
136
|
Completion notification semantics:
|
|
137
137
|
- oracle responses and artifacts are always persisted durably in oracle job state under the configured oracle jobs dir (`${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/` by default)
|
|
138
138
|
- completion delivery into pi sessions is best-effort wake-up based; the extension no longer appends synthetic assistant completion messages into session history
|
|
139
|
-
-
|
|
139
|
+
- wake-up content tells the receiving assistant to call `oracle_read(jobId)` as the canonical completion-consumption path, with saved file paths included as secondary context
|
|
140
|
+
- manual `oracle_read` or `/oracle-status` inspection settles further reminder retries once the terminal job has been opened, and now persists settlement provenance for postmortems
|
|
141
|
+
- manual inspection before the first wake-up attempt is recorded as observation only and does not suppress the first reminder send
|
|
140
142
|
- if a wake-up does not land, the job remains available via its saved response/artifacts and status commands
|
|
141
143
|
|
|
142
144
|
## Privacy / local data
|
package/docs/ORACLE_DESIGN.md
CHANGED
|
@@ -336,6 +336,14 @@ Important fields include:
|
|
|
336
336
|
- `wakeupAttemptCount`
|
|
337
337
|
- `wakeupLastRequestedAt`
|
|
338
338
|
- `wakeupSettledAt`
|
|
339
|
+
- `wakeupSettledSource`
|
|
340
|
+
- `wakeupSettledSessionFile`
|
|
341
|
+
- `wakeupSettledSessionKey`
|
|
342
|
+
- `wakeupSettledBeforeFirstAttempt`
|
|
343
|
+
- `wakeupObservedAt`
|
|
344
|
+
- `wakeupObservedSource`
|
|
345
|
+
- `wakeupObservedSessionFile`
|
|
346
|
+
- `wakeupObservedSessionKey`
|
|
339
347
|
- `notifyClaimedAt`
|
|
340
348
|
- `notifyClaimedBy`
|
|
341
349
|
- `artifactFailureCount`
|
|
@@ -461,7 +469,9 @@ The extension still uses the same general `pi`-native background completion patt
|
|
|
461
469
|
- poller scans jobs on an interval
|
|
462
470
|
- completed job durability lives in oracle job state plus saved response/artifact files, not in synthetic session-history assistant messages
|
|
463
471
|
- when a matching job reaches `complete`, `failed`, or `cancelled`, the poller issues bounded best-effort wake-up reminders to whichever matching session is currently live
|
|
464
|
-
-
|
|
472
|
+
- those wake-ups direct the receiver to `oracle_read(jobId)` as the canonical completion-consumption path, while still surfacing saved response/artifact paths as secondary context
|
|
473
|
+
- manual `oracle_read` or `/oracle-status` inspection settles further reminder retries once the terminal job has been opened and persists provenance about which path/session settled the wake-up
|
|
474
|
+
- manual inspection before the first wake-up attempt is recorded separately as observation metadata and does not suppress the first reminder send
|
|
465
475
|
- if no wake-up lands, the job remains available via `/oracle-status`, `oracle_read`, and the saved `${PI_ORACLE_JOBS_DIR:-/tmp}/oracle-<job-id>/` response/artifact files
|
|
466
476
|
- because completion delivery is best-effort, pruning uses explicit terminal-job age policy instead of pretending a durable session notification happened
|
|
467
477
|
- recently sent wake-ups keep response/artifact files retained briefly so follow-up turns do not point at deleted paths if cleanup or pruning races with delivery
|
|
@@ -121,7 +121,11 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string,
|
|
|
121
121
|
return;
|
|
122
122
|
}
|
|
123
123
|
if (isTerminalOracleJob(job)) {
|
|
124
|
-
await markWakeupSettled(job.id
|
|
124
|
+
await markWakeupSettled(job.id, {
|
|
125
|
+
source: "oracle_status",
|
|
126
|
+
sessionFile: ctx.sessionManager.getSessionFile?.(),
|
|
127
|
+
cwd: ctx.cwd,
|
|
128
|
+
});
|
|
125
129
|
}
|
|
126
130
|
ctx.ui.notify(summarizeJob(job.id), "info");
|
|
127
131
|
},
|
|
@@ -25,6 +25,8 @@ export type OracleJobPhase =
|
|
|
25
25
|
| "failed"
|
|
26
26
|
| "cancelled";
|
|
27
27
|
|
|
28
|
+
export type OracleWakeupSettlementSource = "oracle_read" | "oracle_status";
|
|
29
|
+
|
|
28
30
|
export const ACTIVE_ORACLE_JOB_STATUSES: OracleJobStatus[] = ["preparing", "submitted", "waiting"];
|
|
29
31
|
export const OPEN_ORACLE_JOB_STATUSES: OracleJobStatus[] = ["queued", ...ACTIVE_ORACLE_JOB_STATUSES];
|
|
30
32
|
export const TERMINAL_ORACLE_JOB_STATUSES: OracleJobStatus[] = ["complete", "failed", "cancelled"];
|
|
@@ -56,6 +58,10 @@ export function shouldAdvanceQueueAfterCancellation(job: Pick<OracleJob, "status
|
|
|
56
58
|
return job.status === "cancelled" && !job.cleanupPending && !job.cleanupWarnings?.length;
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
export function hasRetainedPreSubmitArchive(job: Pick<OracleJob, "submittedAt" | "archiveDeletedAfterUpload" | "archivePath">): boolean {
|
|
62
|
+
return !job.submittedAt && !job.archiveDeletedAfterUpload && typeof job.archivePath === "string" && job.archivePath.length > 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
59
65
|
export function hasDurableWorkerHandoff(
|
|
60
66
|
job: Pick<OracleJob, "status" | "phase" | "workerPid" | "workerStartedAt" | "heartbeatAt">,
|
|
61
67
|
): boolean {
|
|
@@ -147,6 +153,14 @@ export interface OracleJob {
|
|
|
147
153
|
wakeupAttemptCount?: number;
|
|
148
154
|
wakeupLastRequestedAt?: string;
|
|
149
155
|
wakeupSettledAt?: string;
|
|
156
|
+
wakeupSettledSource?: OracleWakeupSettlementSource;
|
|
157
|
+
wakeupSettledSessionFile?: string;
|
|
158
|
+
wakeupSettledSessionKey?: string;
|
|
159
|
+
wakeupSettledBeforeFirstAttempt?: boolean;
|
|
160
|
+
wakeupObservedAt?: string;
|
|
161
|
+
wakeupObservedSource?: OracleWakeupSettlementSource;
|
|
162
|
+
wakeupObservedSessionFile?: string;
|
|
163
|
+
wakeupObservedSessionKey?: string;
|
|
150
164
|
notifyClaimedAt?: string;
|
|
151
165
|
notifyClaimedBy?: string;
|
|
152
166
|
artifactFailureCount?: number;
|
|
@@ -443,17 +457,32 @@ function getTerminalCleanupStaleReason(job: Pick<OracleJob, "status" | "cleanupP
|
|
|
443
457
|
}
|
|
444
458
|
|
|
445
459
|
export async function cleanupJobResources(
|
|
446
|
-
job: Pick<OracleJob, "submittedAt" | "runtimeId" | "runtimeProfileDir" | "runtimeSessionName" | "conversationId">,
|
|
460
|
+
job: Pick<OracleJob, "submittedAt" | "runtimeId" | "runtimeProfileDir" | "runtimeSessionName" | "conversationId" | "archivePath" | "archiveDeletedAfterUpload">,
|
|
447
461
|
): Promise<OracleCleanupReport> {
|
|
462
|
+
const report: OracleCleanupReport = { attempted: [], warnings: [] };
|
|
463
|
+
|
|
464
|
+
if (hasRetainedPreSubmitArchive(job)) {
|
|
465
|
+
report.attempted.push("queuedArchive");
|
|
466
|
+
await rm(job.archivePath, { force: true }).catch((error: Error) => {
|
|
467
|
+
report.warnings.push(`Failed to remove queued archive ${job.archivePath}: ${error.message}`);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
448
471
|
if (!job.submittedAt) {
|
|
449
|
-
return
|
|
472
|
+
return report;
|
|
450
473
|
}
|
|
451
|
-
|
|
474
|
+
|
|
475
|
+
const runtimeReport = await cleanupRuntimeArtifacts({
|
|
452
476
|
runtimeId: job.runtimeId,
|
|
453
477
|
runtimeProfileDir: job.runtimeProfileDir,
|
|
454
478
|
runtimeSessionName: job.runtimeSessionName,
|
|
455
479
|
conversationId: job.conversationId,
|
|
456
480
|
});
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
attempted: [...report.attempted, ...runtimeReport.attempted],
|
|
484
|
+
warnings: [...report.warnings, ...runtimeReport.warnings],
|
|
485
|
+
};
|
|
457
486
|
}
|
|
458
487
|
|
|
459
488
|
function getCleanupRetentionMs(job: OracleJob): { complete: number; failed: number } {
|
|
@@ -774,12 +803,57 @@ export async function noteWakeupRequested(jobId: string, at = new Date().toISOSt
|
|
|
774
803
|
}
|
|
775
804
|
}
|
|
776
805
|
|
|
777
|
-
|
|
806
|
+
function getWakeupSessionKey(sessionFile: string | undefined, cwd: string | undefined): string | undefined {
|
|
807
|
+
if (!sessionFile || !cwd) return undefined;
|
|
808
|
+
const projectId = getProjectId(cwd);
|
|
809
|
+
return `${projectId}::${getSessionId(sessionFile, projectId)}`;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export async function markWakeupSettled(
|
|
813
|
+
jobId: string,
|
|
814
|
+
options: {
|
|
815
|
+
source: OracleWakeupSettlementSource;
|
|
816
|
+
sessionFile?: string;
|
|
817
|
+
cwd?: string;
|
|
818
|
+
at?: string;
|
|
819
|
+
allowBeforeFirstAttempt?: boolean;
|
|
820
|
+
},
|
|
821
|
+
): Promise<OracleJob | undefined> {
|
|
822
|
+
const at = options.at ?? new Date().toISOString();
|
|
823
|
+
const sessionKey = getWakeupSessionKey(options.sessionFile, options.cwd);
|
|
824
|
+
|
|
778
825
|
try {
|
|
779
|
-
return await updateJob(jobId, (job) =>
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
826
|
+
return await updateJob(jobId, (job) => {
|
|
827
|
+
const beforeFirstAttempt = !job.wakeupLastRequestedAt && (job.wakeupAttemptCount ?? 0) === 0;
|
|
828
|
+
if (job.wakeupSettledAt) {
|
|
829
|
+
return {
|
|
830
|
+
...job,
|
|
831
|
+
wakeupSettledSource: job.wakeupSettledSource ?? options.source,
|
|
832
|
+
wakeupSettledSessionFile: job.wakeupSettledSessionFile ?? options.sessionFile,
|
|
833
|
+
wakeupSettledSessionKey: job.wakeupSettledSessionKey ?? sessionKey,
|
|
834
|
+
wakeupSettledBeforeFirstAttempt: job.wakeupSettledBeforeFirstAttempt ?? beforeFirstAttempt,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (beforeFirstAttempt && !options.allowBeforeFirstAttempt) {
|
|
839
|
+
return {
|
|
840
|
+
...job,
|
|
841
|
+
wakeupObservedAt: job.wakeupObservedAt ?? at,
|
|
842
|
+
wakeupObservedSource: job.wakeupObservedSource ?? options.source,
|
|
843
|
+
wakeupObservedSessionFile: job.wakeupObservedSessionFile ?? options.sessionFile,
|
|
844
|
+
wakeupObservedSessionKey: job.wakeupObservedSessionKey ?? sessionKey,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return {
|
|
849
|
+
...job,
|
|
850
|
+
wakeupSettledAt: at,
|
|
851
|
+
wakeupSettledSource: options.source,
|
|
852
|
+
wakeupSettledSessionFile: options.sessionFile,
|
|
853
|
+
wakeupSettledSessionKey: sessionKey,
|
|
854
|
+
wakeupSettledBeforeFirstAttempt: beforeFirstAttempt,
|
|
855
|
+
};
|
|
856
|
+
});
|
|
783
857
|
} catch {
|
|
784
858
|
return readJob(jobId);
|
|
785
859
|
}
|
|
@@ -805,17 +879,14 @@ export async function cancelOracleJob(id: string, reason = "Cancelled by user"):
|
|
|
805
879
|
}, now),
|
|
806
880
|
}));
|
|
807
881
|
|
|
808
|
-
const
|
|
809
|
-
|
|
810
|
-
cleanupWarnings.push(`Failed to remove queued archive ${cancelled.archivePath}: ${error.message}`);
|
|
811
|
-
});
|
|
812
|
-
if (cleanupWarnings.length === 0) return cancelled;
|
|
882
|
+
const cleanupReport = await cleanupJobResources(cancelled);
|
|
883
|
+
if (cleanupReport.warnings.length === 0) return cancelled;
|
|
813
884
|
|
|
814
885
|
return updateJob(id, (job) => ({
|
|
815
886
|
...job,
|
|
816
|
-
cleanupWarnings: [...(job.cleanupWarnings || []), ...
|
|
887
|
+
cleanupWarnings: [...(job.cleanupWarnings || []), ...cleanupReport.warnings],
|
|
817
888
|
lastCleanupAt: now,
|
|
818
|
-
error: [job.error, ...
|
|
889
|
+
error: [job.error, ...cleanupReport.warnings].filter(Boolean).join("\n"),
|
|
819
890
|
}));
|
|
820
891
|
}
|
|
821
892
|
|
|
@@ -180,9 +180,10 @@ function buildNotificationContent(job: OraclePollerJob): string {
|
|
|
180
180
|
const artifactsPath = `${getJobDir(job.id)}/artifacts`;
|
|
181
181
|
return [
|
|
182
182
|
`Oracle job ${job.id} is ${job.status}.`,
|
|
183
|
-
`
|
|
183
|
+
`Use oracle_read with jobId ${job.id} to open the response and settle wake-up retries.`,
|
|
184
|
+
`Response file: ${responsePath}`,
|
|
184
185
|
`Artifacts: ${artifactsPath}`,
|
|
185
|
-
job.error ? `Error: ${job.error}` : "
|
|
186
|
+
job.error ? `Error: ${job.error}` : "After oracle_read, continue from the oracle output.",
|
|
186
187
|
].join("\n");
|
|
187
188
|
}
|
|
188
189
|
|
|
@@ -231,7 +231,7 @@ export async function cloneSeedProfileToRuntime(config: OracleConfig, runtimePro
|
|
|
231
231
|
const AGENT_BROWSER_CLOSE_TIMEOUT_MS = 10_000;
|
|
232
232
|
|
|
233
233
|
export interface OracleCleanupReport {
|
|
234
|
-
attempted: Array<"browser" | "runtimeProfileDir" | "conversationLease" | "runtimeLease">;
|
|
234
|
+
attempted: Array<"browser" | "runtimeProfileDir" | "conversationLease" | "runtimeLease" | "queuedArchive">;
|
|
235
235
|
warnings: string[];
|
|
236
236
|
}
|
|
237
237
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getJobDir,
|
|
14
14
|
getSessionFile,
|
|
15
15
|
hasDurableWorkerHandoff,
|
|
16
|
+
hasRetainedPreSubmitArchive,
|
|
16
17
|
isOpenOracleJob,
|
|
17
18
|
isTerminalOracleJob,
|
|
18
19
|
listOracleJobDirs,
|
|
@@ -436,27 +437,60 @@ async function createArchive(cwd: string, files: string[], archivePath: string):
|
|
|
436
437
|
return createArchiveForTesting(cwd, files, archivePath);
|
|
437
438
|
}
|
|
438
439
|
|
|
439
|
-
|
|
440
|
-
|
|
440
|
+
export interface QueuedArchivePressure {
|
|
441
|
+
queuedJobs: number;
|
|
442
|
+
queuedArchiveBytes: number;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export async function getQueuedArchivePressure(): Promise<QueuedArchivePressure> {
|
|
446
|
+
const jobs = listOracleJobDirs()
|
|
441
447
|
.map((dir) => readJob(dir))
|
|
442
|
-
.filter((job): job is NonNullable<typeof job> => Boolean(job
|
|
448
|
+
.filter((job): job is NonNullable<typeof job> => Boolean(job));
|
|
443
449
|
|
|
444
450
|
const queuedArchiveBytes = (await Promise.all(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
451
|
+
jobs
|
|
452
|
+
.filter((job) => hasRetainedPreSubmitArchive(job))
|
|
453
|
+
.map(async (job) => {
|
|
454
|
+
try {
|
|
455
|
+
return (await stat(job.archivePath)).size;
|
|
456
|
+
} catch {
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
}),
|
|
452
460
|
)).reduce((sum, bytes) => sum + bytes, 0);
|
|
453
461
|
|
|
454
462
|
return {
|
|
455
|
-
queuedJobs:
|
|
463
|
+
queuedJobs: jobs.filter((job) => job.status === "queued").length,
|
|
456
464
|
queuedArchiveBytes,
|
|
457
465
|
};
|
|
458
466
|
}
|
|
459
467
|
|
|
468
|
+
export function getQueueAdmissionFailure(args: {
|
|
469
|
+
queuePressure: QueuedArchivePressure;
|
|
470
|
+
archiveBytes: number;
|
|
471
|
+
activeJobs: number;
|
|
472
|
+
maxActiveJobs: number;
|
|
473
|
+
maxQueuedJobs: number;
|
|
474
|
+
maxQueuedArchiveBytes: number;
|
|
475
|
+
}): string | undefined {
|
|
476
|
+
if (args.queuePressure.queuedJobs >= args.maxQueuedJobs) {
|
|
477
|
+
return (
|
|
478
|
+
`Oracle is busy (${args.activeJobs}/${args.maxActiveJobs} active, ${args.queuePressure.queuedJobs}/${args.maxQueuedJobs} queued). ` +
|
|
479
|
+
"Retry later instead of enqueuing more archive state."
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const queuedArchiveBytes = args.queuePressure.queuedArchiveBytes + args.archiveBytes;
|
|
484
|
+
if (queuedArchiveBytes > args.maxQueuedArchiveBytes) {
|
|
485
|
+
return (
|
|
486
|
+
`Oracle queued archive storage is full (${queuedArchiveBytes} bytes > ${args.maxQueuedArchiveBytes} bytes across queued jobs and retained pre-submit archives). ` +
|
|
487
|
+
"Retry later or narrow the archive inputs."
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return undefined;
|
|
492
|
+
}
|
|
493
|
+
|
|
460
494
|
function validateSubmissionOptions(
|
|
461
495
|
params: { effort?: OracleEffort; autoSwitchToThinking?: boolean },
|
|
462
496
|
modelFamily: OracleModelFamily,
|
|
@@ -638,18 +672,17 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
638
672
|
if (!runtimeAttempt.acquired) {
|
|
639
673
|
const queuePressure = await getQueuedArchivePressure();
|
|
640
674
|
const maxQueuedJobs = config.browser.maxConcurrentJobs * MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME;
|
|
641
|
-
if (queuePressure.queuedJobs >= maxQueuedJobs) {
|
|
642
|
-
throw new Error(
|
|
643
|
-
`Oracle is busy (${runtimeAttempt.liveLeases.length}/${config.browser.maxConcurrentJobs} active, ${queuePressure.queuedJobs}/${maxQueuedJobs} queued). ` +
|
|
644
|
-
"Retry later instead of enqueuing more archive state.",
|
|
645
|
-
);
|
|
646
|
-
}
|
|
647
675
|
const maxQueuedArchiveBytes = config.browser.maxConcurrentJobs * MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME;
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
676
|
+
const queueAdmissionFailure = getQueueAdmissionFailure({
|
|
677
|
+
queuePressure,
|
|
678
|
+
archiveBytes: currentArchive.archiveBytes,
|
|
679
|
+
activeJobs: runtimeAttempt.liveLeases.length,
|
|
680
|
+
maxActiveJobs: config.browser.maxConcurrentJobs,
|
|
681
|
+
maxQueuedJobs,
|
|
682
|
+
maxQueuedArchiveBytes,
|
|
683
|
+
});
|
|
684
|
+
if (queueAdmissionFailure) {
|
|
685
|
+
throw new Error(queueAdmissionFailure);
|
|
653
686
|
}
|
|
654
687
|
|
|
655
688
|
queued = true;
|
|
@@ -854,7 +887,13 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string): void
|
|
|
854
887
|
if (!job || job.projectId !== getProjectId(ctx.cwd)) {
|
|
855
888
|
throw new Error(`Oracle job not found in this project: ${params.jobId}`);
|
|
856
889
|
}
|
|
857
|
-
const latest = isTerminalOracleJob(job)
|
|
890
|
+
const latest = isTerminalOracleJob(job)
|
|
891
|
+
? await markWakeupSettled(job.id, {
|
|
892
|
+
source: "oracle_read",
|
|
893
|
+
sessionFile: getSessionFile(ctx),
|
|
894
|
+
cwd: ctx.cwd,
|
|
895
|
+
})
|
|
896
|
+
: job;
|
|
858
897
|
const current = latest ?? readJob(job.id) ?? job;
|
|
859
898
|
|
|
860
899
|
let responsePreview = "";
|
|
@@ -10,20 +10,36 @@ export interface SnapshotEntry {
|
|
|
10
10
|
|
|
11
11
|
export interface StructuralArtifactCandidateInput {
|
|
12
12
|
label?: string;
|
|
13
|
+
selector?: string;
|
|
14
|
+
controlLabel?: string;
|
|
13
15
|
paragraphText?: string;
|
|
14
16
|
listItemText?: string;
|
|
15
|
-
|
|
17
|
+
paragraphInteractiveCount?: number;
|
|
18
|
+
paragraphArtifactLabelCount?: number;
|
|
16
19
|
paragraphOtherTextLength?: number;
|
|
17
|
-
|
|
18
|
-
|
|
20
|
+
listItemInteractiveCount?: number;
|
|
21
|
+
listItemArtifactLabelCount?: number;
|
|
22
|
+
focusableInteractiveCount?: number;
|
|
23
|
+
focusableArtifactLabelCount?: number;
|
|
19
24
|
focusableOtherTextLength?: number;
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
export interface StructuralArtifactCandidate {
|
|
23
28
|
label: string;
|
|
29
|
+
selector?: string;
|
|
30
|
+
controlLabel?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StructuralArtifactCandidatePartition {
|
|
34
|
+
confirmed: StructuralArtifactCandidate[];
|
|
35
|
+
suspicious: StructuralArtifactCandidate[];
|
|
24
36
|
}
|
|
25
37
|
|
|
26
38
|
export function parseSnapshotEntries(snapshot: string): SnapshotEntry[];
|
|
39
|
+
export function extractArtifactLabels(value: string): string[];
|
|
27
40
|
export function filterStructuralArtifactCandidates(
|
|
28
41
|
candidates: StructuralArtifactCandidateInput[],
|
|
29
42
|
): StructuralArtifactCandidate[];
|
|
43
|
+
export function partitionStructuralArtifactCandidates(
|
|
44
|
+
candidates: StructuralArtifactCandidateInput[],
|
|
45
|
+
): StructuralArtifactCandidatePartition;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export const FILE_LABEL_PATTERN_SOURCE = String.raw`(?:^|[
|
|
2
|
-
const FILE_LABEL_PATTERN = new RegExp(FILE_LABEL_PATTERN_SOURCE);
|
|
1
|
+
export const FILE_LABEL_PATTERN_SOURCE = String.raw`(?:^|[^A-Za-z0-9._~/-])((?:(?:[A-Za-z]:)?[\\/]|[.~][\\/])?(?:[^\\/\s"'<>|]+[\\/])*[^\\/\s"'<>|]+\.[A-Za-z0-9]{1,12})(?=$|[^A-Za-z0-9._~/-])`;
|
|
2
|
+
const FILE_LABEL_PATTERN = new RegExp(FILE_LABEL_PATTERN_SOURCE, "g");
|
|
3
3
|
export const GENERIC_ARTIFACT_LABELS = ["ATTACHED", "DONE"];
|
|
4
4
|
const GENERIC_ARTIFACT_LABEL_SET = new Set(GENERIC_ARTIFACT_LABELS);
|
|
5
|
+
const GENERIC_DOWNLOAD_CONTROL_PATTERN = /(?:^|\b)(?:download|save)(?:\b|$)/i;
|
|
5
6
|
|
|
6
7
|
export function parseSnapshotEntries(snapshot) {
|
|
7
8
|
return String(snapshot || "")
|
|
@@ -29,11 +30,61 @@ function normalizeText(value) {
|
|
|
29
30
|
return String(value || "").replace(/\s+/g, " ").trim();
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
function sanitizeArtifactLabel(value) {
|
|
34
|
+
const normalized = normalizeText(value).replace(/^[^A-Za-z0-9._~/-]+|[^A-Za-z0-9._~/-]+$/g, "");
|
|
35
|
+
if (!normalized) return "";
|
|
36
|
+
const basename = normalized.split(/[\\/]/).filter(Boolean).at(-1) || "";
|
|
37
|
+
return basename.replace(/^[^A-Za-z0-9._-]+|[^A-Za-z0-9._-]+$/g, "");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function extractArtifactLabels(value) {
|
|
41
|
+
const seen = new Set();
|
|
42
|
+
const labels = [];
|
|
43
|
+
for (const match of String(value || "").matchAll(FILE_LABEL_PATTERN)) {
|
|
44
|
+
const normalized = sanitizeArtifactLabel(match[1] || match[0] || "");
|
|
45
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
46
|
+
seen.add(normalized);
|
|
47
|
+
labels.push(normalized);
|
|
48
|
+
}
|
|
49
|
+
return labels;
|
|
50
|
+
}
|
|
51
|
+
|
|
32
52
|
export function isLikelyArtifactLabel(label) {
|
|
33
53
|
const normalized = normalizeText(label);
|
|
34
54
|
if (!normalized) return false;
|
|
35
55
|
if (GENERIC_ARTIFACT_LABEL_SET.has(normalized.toUpperCase())) return true;
|
|
36
|
-
return
|
|
56
|
+
return extractArtifactLabels(normalized).length > 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function hasGenericDownloadControl(controlLabel) {
|
|
60
|
+
return GENERIC_DOWNLOAD_CONTROL_PATTERN.test(normalizeText(controlLabel));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeCandidate(candidate) {
|
|
64
|
+
const label = normalizeText(candidate?.label);
|
|
65
|
+
return label ? { ...candidate, label } : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasArtifactSignal(candidate) {
|
|
69
|
+
const label = normalizeText(candidate?.label);
|
|
70
|
+
if (!isLikelyArtifactLabel(label)) return false;
|
|
71
|
+
|
|
72
|
+
const paragraphInteractiveCount = Number(candidate?.paragraphInteractiveCount || 0);
|
|
73
|
+
const listItemInteractiveCount = Number(candidate?.listItemInteractiveCount || 0);
|
|
74
|
+
const focusableInteractiveCount = Number(candidate?.focusableInteractiveCount || 0);
|
|
75
|
+
const paragraphArtifactLabelCount = Number(candidate?.paragraphArtifactLabelCount || 0);
|
|
76
|
+
const listItemArtifactLabelCount = Number(candidate?.listItemArtifactLabelCount || 0);
|
|
77
|
+
const focusableArtifactLabelCount = Number(candidate?.focusableArtifactLabelCount || 0);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
hasGenericDownloadControl(candidate?.controlLabel) ||
|
|
81
|
+
paragraphInteractiveCount > 0 ||
|
|
82
|
+
listItemInteractiveCount > 0 ||
|
|
83
|
+
focusableInteractiveCount > 0 ||
|
|
84
|
+
paragraphArtifactLabelCount > 0 ||
|
|
85
|
+
listItemArtifactLabelCount > 0 ||
|
|
86
|
+
focusableArtifactLabelCount > 0
|
|
87
|
+
);
|
|
37
88
|
}
|
|
38
89
|
|
|
39
90
|
export function isStructuralArtifactCandidate(candidate) {
|
|
@@ -41,36 +92,56 @@ export function isStructuralArtifactCandidate(candidate) {
|
|
|
41
92
|
if (!isLikelyArtifactLabel(label)) return false;
|
|
42
93
|
|
|
43
94
|
const listItemText = normalizeText(candidate?.listItemText);
|
|
44
|
-
const
|
|
45
|
-
const
|
|
95
|
+
const listItemInteractiveCount = Number(candidate?.listItemInteractiveCount || 0);
|
|
96
|
+
const listItemArtifactLabelCount = Number(candidate?.listItemArtifactLabelCount || 0);
|
|
97
|
+
const paragraphInteractiveCount = Number(candidate?.paragraphInteractiveCount || 0);
|
|
98
|
+
const paragraphArtifactLabelCount = Number(candidate?.paragraphArtifactLabelCount || 0);
|
|
46
99
|
const paragraphOtherTextLength = Number(candidate?.paragraphOtherTextLength ?? Number.POSITIVE_INFINITY);
|
|
47
|
-
const
|
|
100
|
+
const focusableInteractiveCount = Number(candidate?.focusableInteractiveCount || 0);
|
|
101
|
+
const focusableArtifactLabelCount = Number(candidate?.focusableArtifactLabelCount || 0);
|
|
48
102
|
const focusableOtherTextLength = Number(candidate?.focusableOtherTextLength ?? Number.POSITIVE_INFINITY);
|
|
49
103
|
|
|
50
|
-
if (listItemText === label &&
|
|
104
|
+
if (listItemText === label && listItemInteractiveCount === 1 && listItemArtifactLabelCount === 1) {
|
|
51
105
|
return true;
|
|
52
106
|
}
|
|
53
107
|
|
|
54
|
-
if (
|
|
108
|
+
if (paragraphArtifactLabelCount === 1 && paragraphInteractiveCount === 1 && paragraphOtherTextLength <= 32) {
|
|
55
109
|
return true;
|
|
56
110
|
}
|
|
57
111
|
|
|
58
|
-
if (
|
|
112
|
+
if (focusableArtifactLabelCount >= 1 && focusableInteractiveCount >= 1 && focusableOtherTextLength <= 64) {
|
|
59
113
|
return true;
|
|
60
114
|
}
|
|
61
115
|
|
|
62
116
|
return false;
|
|
63
117
|
}
|
|
64
118
|
|
|
65
|
-
export function
|
|
66
|
-
const
|
|
67
|
-
const
|
|
119
|
+
export function partitionStructuralArtifactCandidates(candidates) {
|
|
120
|
+
const confirmedSeen = new Set();
|
|
121
|
+
const suspiciousSeen = new Set();
|
|
122
|
+
const confirmed = [];
|
|
123
|
+
const suspicious = [];
|
|
124
|
+
|
|
68
125
|
for (const candidate of candidates || []) {
|
|
69
|
-
const
|
|
70
|
-
if (!
|
|
71
|
-
if (!
|
|
72
|
-
|
|
73
|
-
|
|
126
|
+
const normalized = normalizeCandidate(candidate);
|
|
127
|
+
if (!normalized) continue;
|
|
128
|
+
if (!hasArtifactSignal(normalized)) continue;
|
|
129
|
+
|
|
130
|
+
if (isStructuralArtifactCandidate(normalized)) {
|
|
131
|
+
if (confirmedSeen.has(normalized.label)) continue;
|
|
132
|
+
confirmedSeen.add(normalized.label);
|
|
133
|
+
confirmed.push(normalized);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (suspiciousSeen.has(normalized.label)) continue;
|
|
138
|
+
suspiciousSeen.add(normalized.label);
|
|
139
|
+
suspicious.push(normalized);
|
|
74
140
|
}
|
|
75
|
-
|
|
141
|
+
|
|
142
|
+
return { confirmed, suspicious: suspicious.filter((candidate) => !confirmedSeen.has(candidate.label)) };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function filterStructuralArtifactCandidates(candidates) {
|
|
146
|
+
return partitionStructuralArtifactCandidates(candidates).confirmed;
|
|
76
147
|
}
|
|
@@ -4,7 +4,7 @@ import { appendFile, chmod, mkdir, readFile, rename, rm, stat, writeFile } from
|
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { spawn, execFileSync } from "node:child_process";
|
|
7
|
-
import {
|
|
7
|
+
import { extractArtifactLabels, FILE_LABEL_PATTERN_SOURCE, GENERIC_ARTIFACT_LABELS, parseSnapshotEntries, partitionStructuralArtifactCandidates } from "./artifact-heuristics.mjs";
|
|
8
8
|
import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withLock } from "./state-locks.mjs";
|
|
9
9
|
|
|
10
10
|
const jobId = process.argv[2];
|
|
@@ -1497,28 +1497,52 @@ function preferredArtifactName(label, index) {
|
|
|
1497
1497
|
async function collectArtifactCandidates(job, responseIndex, responseText = "") {
|
|
1498
1498
|
const snapshot = await snapshotText(job);
|
|
1499
1499
|
const targetSlice = assistantSnapshotSlice(snapshot, responseIndex);
|
|
1500
|
-
if (!targetSlice) return { snapshot, targetSlice, candidates: [] };
|
|
1500
|
+
if (!targetSlice) return { snapshot, targetSlice, candidates: [], suspiciousLabels: [] };
|
|
1501
1501
|
|
|
1502
1502
|
const structural = await evalPage(
|
|
1503
1503
|
job,
|
|
1504
1504
|
toJsonScript(`
|
|
1505
1505
|
const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
|
1506
1506
|
const genericArtifactLabels = new Set(${JSON.stringify(GENERIC_ARTIFACT_LABELS)});
|
|
1507
|
-
const fileLabelPattern = new RegExp(${JSON.stringify(FILE_LABEL_PATTERN_SOURCE)});
|
|
1507
|
+
const fileLabelPattern = new RegExp(${JSON.stringify(FILE_LABEL_PATTERN_SOURCE)}, 'g');
|
|
1508
|
+
const downloadControlPattern = /(?:^|\\b)(?:download|save)(?:\\b|$)/i;
|
|
1509
|
+
const artifactMarkerAttr = 'data-pi-oracle-artifact-candidate';
|
|
1510
|
+
const artifactPrefix = 'pi-oracle-artifact-${jobId}-${responseIndex}-';
|
|
1511
|
+
const sanitize = (value) => normalize(value).replace(/^[^A-Za-z0-9._~/-]+|[^A-Za-z0-9._~/-]+$/g, '');
|
|
1512
|
+
const sanitizeArtifactLabel = (value) => {
|
|
1513
|
+
const normalized = sanitize(value);
|
|
1514
|
+
if (!normalized) return '';
|
|
1515
|
+
const basename = normalized.split(/[\\/]/).filter(Boolean).at(-1) || '';
|
|
1516
|
+
return basename.replace(/^[^A-Za-z0-9._-]+|[^A-Za-z0-9._-]+$/g, '');
|
|
1517
|
+
};
|
|
1518
|
+
const extractArtifactLabels = (value) => {
|
|
1519
|
+
const seen = new Set();
|
|
1520
|
+
const labels = [];
|
|
1521
|
+
for (const match of String(value || '').matchAll(fileLabelPattern)) {
|
|
1522
|
+
const label = sanitizeArtifactLabel(match[1] || match[0] || '');
|
|
1523
|
+
if (!label || seen.has(label)) continue;
|
|
1524
|
+
seen.add(label);
|
|
1525
|
+
labels.push(label);
|
|
1526
|
+
}
|
|
1527
|
+
return labels;
|
|
1528
|
+
};
|
|
1508
1529
|
const isFileLabel = (value) => {
|
|
1509
1530
|
const normalized = normalize(value);
|
|
1510
1531
|
if (!normalized) return false;
|
|
1511
1532
|
if (genericArtifactLabels.has(normalized.toUpperCase())) return true;
|
|
1512
|
-
return
|
|
1533
|
+
return extractArtifactLabels(normalized).length > 0;
|
|
1513
1534
|
};
|
|
1535
|
+
const isDownloadControl = (value) => downloadControlPattern.test(normalize(value));
|
|
1514
1536
|
const headings = Array.from(document.querySelectorAll('h1,h2,h3,h4,h5,h6,[role="heading"]'))
|
|
1515
1537
|
.filter((el) => normalize(el.textContent) === 'ChatGPT said:');
|
|
1516
1538
|
const host = headings[${responseIndex}]?.nextElementSibling;
|
|
1517
1539
|
if (!host) return { candidates: [] };
|
|
1518
1540
|
|
|
1519
|
-
const
|
|
1520
|
-
|
|
1521
|
-
|
|
1541
|
+
const interactiveElements = (node) => node ? Array.from(node.querySelectorAll('button, a')) : [];
|
|
1542
|
+
const interactiveLabels = (node) => interactiveElements(node)
|
|
1543
|
+
.map((candidate) => normalize(candidate.textContent || candidate.getAttribute('aria-label') || candidate.getAttribute('title')))
|
|
1544
|
+
.filter(Boolean);
|
|
1545
|
+
const artifactLabelsForNode = (node) => extractArtifactLabels(node?.textContent || '');
|
|
1522
1546
|
const otherTextLength = (text, labels) => {
|
|
1523
1547
|
let remaining = normalize(text);
|
|
1524
1548
|
for (const label of labels || []) {
|
|
@@ -1528,25 +1552,43 @@ async function collectArtifactCandidates(job, responseIndex, responseText = "")
|
|
|
1528
1552
|
return remaining.length;
|
|
1529
1553
|
};
|
|
1530
1554
|
const focusableFor = (node) => node?.closest('[tabindex]');
|
|
1555
|
+
const uniqueLabel = (...groups) => {
|
|
1556
|
+
for (const group of groups) {
|
|
1557
|
+
const labels = Array.from(new Set((group || []).map(sanitizeArtifactLabel).filter(Boolean)));
|
|
1558
|
+
if (labels.length === 1) return labels[0];
|
|
1559
|
+
}
|
|
1560
|
+
return undefined;
|
|
1561
|
+
};
|
|
1531
1562
|
|
|
1532
|
-
const candidates =
|
|
1533
|
-
.map((button) => {
|
|
1534
|
-
const
|
|
1535
|
-
if (!isFileLabel(label)) return null;
|
|
1563
|
+
const candidates = interactiveElements(host)
|
|
1564
|
+
.map((button, index) => {
|
|
1565
|
+
const controlLabel = normalize(button.textContent || button.getAttribute('aria-label') || button.getAttribute('title'));
|
|
1536
1566
|
const paragraph = button.closest('p');
|
|
1537
1567
|
const listItem = button.closest('li');
|
|
1538
1568
|
const focusable = focusableFor(button);
|
|
1539
|
-
const
|
|
1540
|
-
const
|
|
1569
|
+
const ownArtifactLabels = extractArtifactLabels(controlLabel);
|
|
1570
|
+
const paragraphArtifactLabels = artifactLabelsForNode(paragraph);
|
|
1571
|
+
const listItemArtifactLabels = artifactLabelsForNode(listItem);
|
|
1572
|
+
const focusableArtifactLabels = artifactLabelsForNode(focusable);
|
|
1573
|
+
const label = uniqueLabel(ownArtifactLabels, listItemArtifactLabels, paragraphArtifactLabels, focusableArtifactLabels);
|
|
1574
|
+
if (!label && !isFileLabel(controlLabel) && !isDownloadControl(controlLabel)) return null;
|
|
1575
|
+
if (!label) return null;
|
|
1576
|
+
const marker = artifactPrefix + index;
|
|
1577
|
+
button.setAttribute(artifactMarkerAttr, marker);
|
|
1541
1578
|
return {
|
|
1542
1579
|
label,
|
|
1580
|
+
selector: '[' + artifactMarkerAttr + '="' + marker + '"]',
|
|
1581
|
+
controlLabel,
|
|
1543
1582
|
paragraphText: normalize(paragraph?.textContent),
|
|
1544
1583
|
listItemText: normalize(listItem?.textContent),
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1584
|
+
paragraphInteractiveCount: interactiveElements(paragraph).length,
|
|
1585
|
+
paragraphArtifactLabelCount: Array.from(new Set(paragraphArtifactLabels)).length,
|
|
1586
|
+
paragraphOtherTextLength: otherTextLength(paragraph?.textContent, [...paragraphArtifactLabels, ...interactiveLabels(paragraph)]),
|
|
1587
|
+
listItemInteractiveCount: interactiveElements(listItem).length,
|
|
1588
|
+
listItemArtifactLabelCount: Array.from(new Set(listItemArtifactLabels)).length,
|
|
1589
|
+
focusableInteractiveCount: interactiveElements(focusable).length,
|
|
1590
|
+
focusableArtifactLabelCount: Array.from(new Set(focusableArtifactLabels)).length,
|
|
1591
|
+
focusableOtherTextLength: otherTextLength(focusable?.textContent, [...focusableArtifactLabels, ...interactiveLabels(focusable)]),
|
|
1550
1592
|
};
|
|
1551
1593
|
})
|
|
1552
1594
|
.filter(Boolean);
|
|
@@ -1555,10 +1597,26 @@ async function collectArtifactCandidates(job, responseIndex, responseText = "")
|
|
|
1555
1597
|
`),
|
|
1556
1598
|
);
|
|
1557
1599
|
|
|
1600
|
+
const partitioned = partitionStructuralArtifactCandidates(structural?.candidates || []);
|
|
1601
|
+
const snapshotEntries = parseSnapshotEntries(targetSlice);
|
|
1602
|
+
const hasGenericArtifactControl = snapshotEntries.some(
|
|
1603
|
+
(entry) =>
|
|
1604
|
+
(entry.kind === "button" || entry.kind === "link") &&
|
|
1605
|
+
!entry.disabled &&
|
|
1606
|
+
/(?:^|\b)(?:download|save)(?:\b|$)/i.test(`${entry.label || ""} ${entry.value || ""}`),
|
|
1607
|
+
);
|
|
1608
|
+
const suspiciousFromText = hasGenericArtifactControl
|
|
1609
|
+
? extractArtifactLabels(responseText)
|
|
1610
|
+
.filter((label) => !partitioned.confirmed.some((candidate) => candidate.label === label) && !partitioned.suspicious.some((candidate) => candidate.label === label))
|
|
1611
|
+
.map((label) => ({ label }))
|
|
1612
|
+
: [];
|
|
1613
|
+
|
|
1558
1614
|
return {
|
|
1559
1615
|
snapshot,
|
|
1560
1616
|
targetSlice,
|
|
1561
|
-
candidates:
|
|
1617
|
+
candidates: partitioned.confirmed,
|
|
1618
|
+
suspiciousLabels: [...partitioned.suspicious.map((candidate) => candidate.label), ...suspiciousFromText.map((candidate) => candidate.label)]
|
|
1619
|
+
.filter((label, index, labels) => labels.indexOf(label) === index),
|
|
1562
1620
|
};
|
|
1563
1621
|
}
|
|
1564
1622
|
|
|
@@ -1566,11 +1624,14 @@ async function waitForStableArtifactCandidates(job, responseIndex, responseText
|
|
|
1566
1624
|
const deadline = Date.now() + ARTIFACT_CANDIDATE_STABILITY_TIMEOUT_MS;
|
|
1567
1625
|
let lastSignature;
|
|
1568
1626
|
let stablePolls = 0;
|
|
1569
|
-
let latest = { snapshot: "", targetSlice: undefined, candidates: [] };
|
|
1627
|
+
let latest = { snapshot: "", targetSlice: undefined, candidates: [], suspiciousLabels: [] };
|
|
1570
1628
|
|
|
1571
1629
|
while (Date.now() < deadline) {
|
|
1572
1630
|
latest = await collectArtifactCandidates(job, responseIndex, responseText);
|
|
1573
|
-
const signature =
|
|
1631
|
+
const signature = JSON.stringify({
|
|
1632
|
+
candidates: latest.candidates.map((candidate) => candidate.label),
|
|
1633
|
+
suspiciousLabels: latest.suspiciousLabels,
|
|
1634
|
+
});
|
|
1574
1635
|
if (signature === lastSignature) stablePolls += 1;
|
|
1575
1636
|
else {
|
|
1576
1637
|
lastSignature = signature;
|
|
@@ -1628,7 +1689,7 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
|
|
|
1628
1689
|
return [];
|
|
1629
1690
|
}
|
|
1630
1691
|
|
|
1631
|
-
|
|
1692
|
+
let { targetSlice, candidates, suspiciousLabels } = await reopenConversationForArtifacts(job, responseIndex, responseText, "initial");
|
|
1632
1693
|
if (!targetSlice) {
|
|
1633
1694
|
await log(`No assistant response found in snapshot for response index ${responseIndex}`);
|
|
1634
1695
|
await secureWriteText(`${jobDir}/artifacts.json`, "[]\n");
|
|
@@ -1637,33 +1698,32 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
|
|
|
1637
1698
|
}
|
|
1638
1699
|
|
|
1639
1700
|
await log(`Artifact candidates: ${candidates.map((candidate) => candidate.label).join(", ") || "(none)"}`);
|
|
1701
|
+
if (suspiciousLabels.length > 0) {
|
|
1702
|
+
await log(`Suspicious artifact signals: ${suspiciousLabels.join(", ")}`);
|
|
1703
|
+
}
|
|
1640
1704
|
|
|
1641
1705
|
const artifactsDir = `${jobDir}/artifacts`;
|
|
1642
1706
|
await ensurePrivateDir(artifactsDir);
|
|
1643
1707
|
const artifacts = [];
|
|
1644
1708
|
await flushArtifactsState(artifacts);
|
|
1645
1709
|
|
|
1646
|
-
for (const [index,
|
|
1710
|
+
for (const [index, originalCandidate] of candidates.entries()) {
|
|
1647
1711
|
let downloaded = false;
|
|
1712
|
+
let activeCandidate = originalCandidate;
|
|
1648
1713
|
for (let attempt = 1; attempt <= ARTIFACT_DOWNLOAD_MAX_ATTEMPTS && !downloaded; attempt += 1) {
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
const entry = freshEntries.find(
|
|
1654
|
-
(artifactEntry) => artifactEntry.label === candidate.label && (artifactEntry.kind === "button" || artifactEntry.kind === "link") && !artifactEntry.disabled,
|
|
1655
|
-
);
|
|
1656
|
-
if (!entry) {
|
|
1657
|
-
await log(`Artifact "${candidate.label}" not found in fresh snapshot, skipping`);
|
|
1714
|
+
if (!activeCandidate?.selector) {
|
|
1715
|
+
await log(`Artifact "${originalCandidate.label}" has no live selector, marking unconfirmed`);
|
|
1716
|
+
artifacts.push({ displayName: originalCandidate.label, unconfirmed: true, error: "Artifact candidate lost its live selector before download." });
|
|
1717
|
+
await flushArtifactsState(artifacts);
|
|
1658
1718
|
break;
|
|
1659
1719
|
}
|
|
1660
1720
|
|
|
1661
|
-
const destinationPath = join(artifactsDir, preferredArtifactName(
|
|
1721
|
+
const destinationPath = join(artifactsDir, preferredArtifactName(originalCandidate.label, index));
|
|
1662
1722
|
await rm(destinationPath, { force: true }).catch(() => undefined);
|
|
1663
1723
|
try {
|
|
1664
|
-
await log(`Artifact "${
|
|
1724
|
+
await log(`Artifact "${originalCandidate.label}" download attempt ${attempt}/${ARTIFACT_DOWNLOAD_MAX_ATTEMPTS} using selector ${activeCandidate.selector}`);
|
|
1665
1725
|
await withHeartbeatWhile(() =>
|
|
1666
|
-
agentBrowser(job, "download",
|
|
1726
|
+
agentBrowser(job, "download", activeCandidate.selector, destinationPath, {
|
|
1667
1727
|
timeoutMs: ARTIFACT_DOWNLOAD_TIMEOUT_MS,
|
|
1668
1728
|
}),
|
|
1669
1729
|
);
|
|
@@ -1675,7 +1735,7 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
|
|
|
1675
1735
|
detectType(destinationPath),
|
|
1676
1736
|
]);
|
|
1677
1737
|
artifacts.push({
|
|
1678
|
-
displayName:
|
|
1738
|
+
displayName: originalCandidate.label,
|
|
1679
1739
|
fileName: basename(destinationPath),
|
|
1680
1740
|
copiedPath: destinationPath,
|
|
1681
1741
|
size,
|
|
@@ -1686,11 +1746,15 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
|
|
|
1686
1746
|
} catch (error) {
|
|
1687
1747
|
const message = error instanceof Error ? error.message : String(error);
|
|
1688
1748
|
await rm(destinationPath, { force: true }).catch(() => undefined);
|
|
1689
|
-
await log(`Artifact "${
|
|
1749
|
+
await log(`Artifact "${originalCandidate.label}" download failed on attempt ${attempt}/${ARTIFACT_DOWNLOAD_MAX_ATTEMPTS}: ${message}`);
|
|
1690
1750
|
if (attempt >= ARTIFACT_DOWNLOAD_MAX_ATTEMPTS) {
|
|
1691
|
-
artifacts.push({ displayName:
|
|
1751
|
+
artifacts.push({ displayName: originalCandidate.label, unconfirmed: true, error: message });
|
|
1692
1752
|
} else {
|
|
1693
|
-
await reopenConversationForArtifacts(job, responseIndex, responseText, `retry ${attempt + 1} for ${
|
|
1753
|
+
const refreshed = await reopenConversationForArtifacts(job, responseIndex, responseText, `retry ${attempt + 1} for ${originalCandidate.label}`);
|
|
1754
|
+
targetSlice = refreshed.targetSlice;
|
|
1755
|
+
candidates = refreshed.candidates;
|
|
1756
|
+
suspiciousLabels = refreshed.suspiciousLabels;
|
|
1757
|
+
activeCandidate = candidates.find((candidate) => candidate.label === originalCandidate.label);
|
|
1694
1758
|
await sleep(1_000);
|
|
1695
1759
|
}
|
|
1696
1760
|
} finally {
|
|
@@ -1699,6 +1763,16 @@ async function downloadArtifacts(job, responseIndex, responseText = "") {
|
|
|
1699
1763
|
}
|
|
1700
1764
|
}
|
|
1701
1765
|
|
|
1766
|
+
const capturedArtifactLabels = new Set(artifacts.map((artifact) => artifact.displayName).filter(Boolean));
|
|
1767
|
+
const missedArtifactLabels = suspiciousLabels.filter((label) => !capturedArtifactLabels.has(label));
|
|
1768
|
+
if (missedArtifactLabels.length > 0) {
|
|
1769
|
+
await log(`Marking missed artifact signals as unconfirmed: ${missedArtifactLabels.join(", ")}`);
|
|
1770
|
+
for (const label of missedArtifactLabels) {
|
|
1771
|
+
artifacts.push({ displayName: label, unconfirmed: true, error: "Response-local artifact signal was present, but no downloadable artifact was captured." });
|
|
1772
|
+
}
|
|
1773
|
+
await flushArtifactsState(artifacts);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1702
1776
|
return artifacts;
|
|
1703
1777
|
}
|
|
1704
1778
|
|
|
@@ -1759,9 +1833,10 @@ async function run() {
|
|
|
1759
1833
|
currentJob = await mutateJob((job) => ({ ...job, ...phasePatch("downloading_artifacts", { heartbeatAt: new Date().toISOString() }) }));
|
|
1760
1834
|
const artifacts = await downloadArtifacts(currentJob, completion.responseIndex, completion.responseText);
|
|
1761
1835
|
const artifactFailureCount = artifacts.filter((artifact) => artifact.unconfirmed || artifact.error).length;
|
|
1836
|
+
const finalPhase = artifactFailureCount > 0 ? "complete_with_artifact_errors" : "complete";
|
|
1762
1837
|
|
|
1763
1838
|
await heartbeat(
|
|
1764
|
-
phasePatch(
|
|
1839
|
+
phasePatch(finalPhase, {
|
|
1765
1840
|
status: "complete",
|
|
1766
1841
|
completedAt: new Date().toISOString(),
|
|
1767
1842
|
responsePath: currentJob.responsePath,
|
|
@@ -1773,7 +1848,7 @@ async function run() {
|
|
|
1773
1848
|
);
|
|
1774
1849
|
const persistedJob = await readJob().catch(() => undefined);
|
|
1775
1850
|
await log(`Persisted final status after completion write: ${persistedJob?.status || "unknown"}`);
|
|
1776
|
-
await log(`Job ${currentJob.id} complete`);
|
|
1851
|
+
await log(`Job ${currentJob.id} complete (${finalPhase}, artifact failures=${artifactFailureCount})`);
|
|
1777
1852
|
} catch (error) {
|
|
1778
1853
|
if (!shuttingDown) {
|
|
1779
1854
|
const message = error instanceof Error ? error.message : String(error);
|