pi-oracle 0.6.3 → 0.6.5
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 +15 -0
- package/extensions/oracle/lib/commands.ts +4 -5
- package/extensions/oracle/lib/jobs.ts +68 -22
- package/extensions/oracle/lib/tools.ts +25 -2
- package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +2 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.mjs +5 -1
- package/extensions/oracle/shared/job-observability-helpers.d.mts +4 -0
- package/extensions/oracle/shared/job-observability-helpers.mjs +64 -0
- package/package.json +9 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.6.5 - 2026-04-15
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- pinned `packageManager` metadata to `npm@11.12.1` and refreshed the release lockfile against the current stable pi toolchain so `verify:oracle` resolves reproducibly across machines
|
|
9
|
+
- kept the published oracle runtime surface unchanged while moving local development dependencies to the current stable pi/TypeScript/Node typing baseline
|
|
10
|
+
|
|
11
|
+
## 0.6.4 - 2026-04-14
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- bumped the local development and release toolchain to the latest published pi packages and current TypeScript/esbuild/Node type definitions so release verification now runs against the same pi generation shipping on this machine
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- `oracle_submit` archive creation now handles downstream `zstd` pipe failures as normal tool errors instead of crashing the host `pi` process with an unhandled `write EPIPE` on newer Node runtimes
|
|
18
|
+
- sanity coverage now exercises the broken-pipe archive path so early downstream compressor exits regress to a clean rejection instead of a process-level crash
|
|
19
|
+
|
|
5
20
|
## 0.6.3 - 2026-04-13
|
|
6
21
|
|
|
7
22
|
### Fixed
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
7
|
import { readFile } from "node:fs/promises";
|
|
8
8
|
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
9
|
-
import { formatOracleJobSummary } from "../shared/job-observability-helpers.mjs";
|
|
9
|
+
import { formatOracleCancelOutcome, formatOracleJobSummary } from "../shared/job-observability-helpers.mjs";
|
|
10
10
|
import { runOracleAuthBootstrap } from "./auth.js";
|
|
11
11
|
import {
|
|
12
12
|
cancelOracleJob,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
isOpenOracleJob,
|
|
15
15
|
isTerminalOracleJob,
|
|
16
16
|
listJobsForCwd,
|
|
17
|
+
ORACLE_STALE_HEARTBEAT_MS,
|
|
17
18
|
markWakeupSettled,
|
|
18
19
|
readJob,
|
|
19
20
|
reconcileStaleOracleJobs,
|
|
@@ -44,6 +45,7 @@ async function summarizeJob(jobId: string, options?: { responsePreview?: boolean
|
|
|
44
45
|
artifactsPath: `${getJobDir(job.id)}/artifacts`,
|
|
45
46
|
responseAvailable,
|
|
46
47
|
responsePreview,
|
|
48
|
+
heartbeatStaleMs: ORACLE_STALE_HEARTBEAT_MS,
|
|
47
49
|
});
|
|
48
50
|
}
|
|
49
51
|
|
|
@@ -153,10 +155,7 @@ export function registerOracleCommands(pi: ExtensionAPI, authWorkerPath: string,
|
|
|
153
155
|
await promoteQueuedJobs({ workerPath, source: "oracle_cancel_command" });
|
|
154
156
|
}
|
|
155
157
|
refreshOracleStatus(ctx);
|
|
156
|
-
|
|
157
|
-
? `Cancelled oracle job ${cancelled.id}`
|
|
158
|
-
: `Oracle job ${cancelled.id} was already ${cancelled.status}`;
|
|
159
|
-
ctx.ui.notify(message, "info");
|
|
158
|
+
ctx.ui.notify(formatOracleCancelOutcome(cancelled), "info");
|
|
160
159
|
},
|
|
161
160
|
});
|
|
162
161
|
|
|
@@ -83,6 +83,19 @@ export function hasPersistedOriginSession(
|
|
|
83
83
|
return typeof job.originSessionFile === "string" && job.originSessionFile.length > 0 && job.sessionId === job.originSessionFile;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
function hasActiveCancelIntent(job: Pick<OracleJob, "status" | "cancelRequestedAt">): boolean {
|
|
87
|
+
return !isTerminalOracleJob(job) && typeof job.cancelRequestedAt === "string" && job.cancelRequestedAt.length > 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function markCancelRequested(job: OracleJob, reason: string, at: string): OracleJob {
|
|
91
|
+
if (hasActiveCancelIntent(job)) return job;
|
|
92
|
+
return {
|
|
93
|
+
...job,
|
|
94
|
+
cancelRequestedAt: at,
|
|
95
|
+
cancelReason: reason,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
86
99
|
export function isWorkerProcessAlive(pid: number | undefined, startedAt?: string): boolean {
|
|
87
100
|
return isTrackedProcessAlive(pid, startedAt);
|
|
88
101
|
}
|
|
@@ -113,6 +126,8 @@ export interface OracleJob {
|
|
|
113
126
|
submittedAt?: string;
|
|
114
127
|
completedAt?: string;
|
|
115
128
|
heartbeatAt?: string;
|
|
129
|
+
cancelRequestedAt?: string;
|
|
130
|
+
cancelReason?: string;
|
|
116
131
|
cwd: string;
|
|
117
132
|
projectId: string;
|
|
118
133
|
sessionId: string;
|
|
@@ -595,6 +610,7 @@ export async function reconcileStaleOracleJobs(): Promise<OracleJob[]> {
|
|
|
595
610
|
const currentStaleReason = getStaleOracleJobReason(current, now);
|
|
596
611
|
if (!currentStaleReason) return;
|
|
597
612
|
|
|
613
|
+
const cancelRequested = hasActiveCancelIntent(current);
|
|
598
614
|
terminated = await terminateWorkerPid(current.workerPid, current.workerStartedAt);
|
|
599
615
|
transitioned = true;
|
|
600
616
|
const suffix = current.workerPid
|
|
@@ -602,19 +618,31 @@ export async function reconcileStaleOracleJobs(): Promise<OracleJob[]> {
|
|
|
602
618
|
? ` Terminated stale worker PID ${current.workerPid}.`
|
|
603
619
|
: ` Failed to terminate stale worker PID ${current.workerPid}.`
|
|
604
620
|
: "";
|
|
605
|
-
repairedJob =
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
}
|
|
617
|
-
|
|
621
|
+
repairedJob = cancelRequested && terminated
|
|
622
|
+
? transitionOracleJobPhase(current, "cancelled", {
|
|
623
|
+
at: recoveredAt,
|
|
624
|
+
source: "oracle:reconcile",
|
|
625
|
+
message: `Recovered requested cancellation: ${currentStaleReason}.${suffix}`.trim(),
|
|
626
|
+
clearNotificationClaim: true,
|
|
627
|
+
patch: {
|
|
628
|
+
heartbeatAt: recoveredAt,
|
|
629
|
+
cleanupPending: true,
|
|
630
|
+
error: current.cancelReason ?? current.error ?? "Cancelled by user",
|
|
631
|
+
},
|
|
632
|
+
})
|
|
633
|
+
: transitionOracleJobPhase(current, "failed", {
|
|
634
|
+
at: recoveredAt,
|
|
635
|
+
source: "oracle:reconcile",
|
|
636
|
+
message: `Recovered stale job: ${currentStaleReason}.${suffix}`.trim(),
|
|
637
|
+
clearNotificationClaim: true,
|
|
638
|
+
patch: {
|
|
639
|
+
heartbeatAt: recoveredAt,
|
|
640
|
+
cleanupPending: terminated,
|
|
641
|
+
error: current.error
|
|
642
|
+
? `${current.error}\nRecovered stale job: ${currentStaleReason}.${suffix}`.trim()
|
|
643
|
+
: `Recovered stale job: ${currentStaleReason}.${suffix}`.trim(),
|
|
644
|
+
},
|
|
645
|
+
});
|
|
618
646
|
await writeJobUnlocked(repairedJob);
|
|
619
647
|
});
|
|
620
648
|
|
|
@@ -803,30 +831,48 @@ export async function cancelOracleJob(id: string, reason = "Cancelled by user"):
|
|
|
803
831
|
}));
|
|
804
832
|
}
|
|
805
833
|
|
|
806
|
-
|
|
834
|
+
let cancelTarget: Pick<OracleJob, "workerPid" | "workerStartedAt"> | undefined;
|
|
835
|
+
await withJobLock(id, { processPid: process.pid, action: "markCancelRequested", jobId: id }, async () => {
|
|
836
|
+
const latest = readJob(id);
|
|
837
|
+
if (!latest || isTerminalOracleJob(latest) || latest.status === "queued") return;
|
|
838
|
+
cancelTarget = { workerPid: latest.workerPid, workerStartedAt: latest.workerStartedAt };
|
|
839
|
+
const next = markCancelRequested(latest, reason, now);
|
|
840
|
+
if (next !== latest) {
|
|
841
|
+
await writeJobUnlocked(next);
|
|
842
|
+
}
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
const terminated = await terminateWorkerPid(cancelTarget?.workerPid, cancelTarget?.workerStartedAt);
|
|
807
846
|
let transitioned = false;
|
|
808
|
-
|
|
809
|
-
|
|
847
|
+
let cancelled: OracleJob | undefined;
|
|
848
|
+
await withJobLock(id, { processPid: process.pid, action: "finalizeCancelOracleJob", jobId: id }, async () => {
|
|
849
|
+
const latest = readJob(id);
|
|
850
|
+
if (!latest) throw new Error(`Oracle job not found: ${id}`);
|
|
851
|
+
if (isTerminalOracleJob(latest)) {
|
|
852
|
+
cancelled = latest;
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
810
855
|
transitioned = true;
|
|
811
|
-
|
|
856
|
+
cancelled = transitionOracleJobPhase(latest, terminated ? "cancelled" : "failed", {
|
|
812
857
|
at: now,
|
|
813
858
|
source: "oracle:cancel",
|
|
814
859
|
message: terminated
|
|
815
|
-
? `Job cancelled: ${reason}`
|
|
816
|
-
: `Job cancellation failed because worker PID ${
|
|
860
|
+
? `Job cancelled: ${latest.cancelReason ?? reason}`
|
|
861
|
+
: `Job cancellation failed because worker PID ${latest.workerPid ?? "unknown"} did not exit.`,
|
|
817
862
|
clearNotificationClaim: true,
|
|
818
863
|
patch: {
|
|
819
864
|
heartbeatAt: now,
|
|
820
865
|
cleanupPending: terminated,
|
|
821
|
-
error: terminated ? reason : `${reason}; worker PID ${
|
|
866
|
+
error: terminated ? latest.cancelReason ?? reason : `${latest.cancelReason ?? reason}; worker PID ${latest.workerPid ?? "unknown"} did not exit`,
|
|
822
867
|
},
|
|
823
868
|
});
|
|
869
|
+
await writeJobUnlocked(cancelled);
|
|
824
870
|
});
|
|
825
|
-
if (!transitioned) return cancelled
|
|
871
|
+
if (!cancelled || !transitioned) return cancelled ?? readJob(id)!;
|
|
826
872
|
|
|
827
873
|
if (!terminated) {
|
|
828
874
|
const cleanupWarnings = [
|
|
829
|
-
`Oracle runtime cleanup is blocked because worker PID ${
|
|
875
|
+
`Oracle runtime cleanup is blocked because worker PID ${cancelled.workerPid ?? cancelTarget?.workerPid ?? "unknown"} could not be terminated safely.`,
|
|
830
876
|
];
|
|
831
877
|
return updateJob(id, (job) => applyOracleJobCleanupWarnings(job, cleanupWarnings, {
|
|
832
878
|
at: now,
|
|
@@ -10,7 +10,7 @@ import { basename, join, posix } from "node:path";
|
|
|
10
10
|
import { runOracleAuthBootstrap } from "./auth.js";
|
|
11
11
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
12
12
|
import { Type } from "@sinclair/typebox";
|
|
13
|
-
import { formatOracleJobSummary, formatOracleSubmitResponse } from "../shared/job-observability-helpers.mjs";
|
|
13
|
+
import { formatOracleCancelOutcome, formatOracleJobSummary, formatOracleSubmitResponse } from "../shared/job-observability-helpers.mjs";
|
|
14
14
|
import { getLatestOracleJobLifecycleEvent, getLatestOracleTerminalLifecycleEvent, transitionOracleJobPhase } from "../shared/job-lifecycle-helpers.mjs";
|
|
15
15
|
import { isLockTimeoutError, withGlobalReconcileLock, withLock } from "./locks.js";
|
|
16
16
|
import {
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
isTerminalOracleJob,
|
|
32
32
|
listOracleJobDirs,
|
|
33
33
|
markWakeupSettled,
|
|
34
|
+
ORACLE_STALE_HEARTBEAT_MS,
|
|
34
35
|
readJob,
|
|
35
36
|
pruneTerminalOracleJobs,
|
|
36
37
|
reconcileStaleOracleJobs,
|
|
@@ -89,6 +90,7 @@ const MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME = 1;
|
|
|
89
90
|
const MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME = MAX_ARCHIVE_BYTES;
|
|
90
91
|
const ARCHIVE_COMMAND_TIMEOUT_MS = 120_000;
|
|
91
92
|
const ARCHIVE_COMMAND_KILL_GRACE_MS = 2_000;
|
|
93
|
+
const ARCHIVE_PIPE_FAILURE_ERROR_CODES = new Set(["EPIPE", "ERR_STREAM_DESTROYED"]);
|
|
92
94
|
|
|
93
95
|
const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
|
|
94
96
|
".git",
|
|
@@ -159,6 +161,12 @@ function appendArchiveEntries(target: string[], source: Iterable<string>): void
|
|
|
159
161
|
for (const entry of source) target.push(entry);
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
function getErrorCode(error: unknown): string | undefined {
|
|
165
|
+
return error && typeof error === "object" && "code" in error && typeof error.code === "string"
|
|
166
|
+
? error.code
|
|
167
|
+
: undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
162
170
|
function mergeArchiveEntryGroups(groups: Iterable<Iterable<string>>): string[] {
|
|
163
171
|
const merged: string[] = [];
|
|
164
172
|
for (const group of groups) appendArchiveEntries(merged, group);
|
|
@@ -415,6 +423,18 @@ async function writeArchiveFile(
|
|
|
415
423
|
else rejectPromise(new Error(stderr || `archive command failed (tar=${tarCode}, zstd=${zstdCode})`));
|
|
416
424
|
};
|
|
417
425
|
|
|
426
|
+
const handlePipeError = (error: unknown) => {
|
|
427
|
+
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
428
|
+
if (ARCHIVE_PIPE_FAILURE_ERROR_CODES.has(getErrorCode(normalized) ?? "")) {
|
|
429
|
+
stderr = `${stderr}${stderr ? "\n" : ""}${normalized.message}`;
|
|
430
|
+
tar.stdout.unpipe(zstd.stdin);
|
|
431
|
+
terminateChildren();
|
|
432
|
+
finish();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
finish(normalized);
|
|
436
|
+
};
|
|
437
|
+
|
|
418
438
|
const commandTimeoutMs = options?.commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS;
|
|
419
439
|
if (commandTimeoutMs > 0) {
|
|
420
440
|
timeout = setTimeout(() => {
|
|
@@ -433,6 +453,8 @@ async function writeArchiveFile(
|
|
|
433
453
|
});
|
|
434
454
|
tar.on("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
|
|
435
455
|
zstd.on("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
|
|
456
|
+
tar.stdout.on("error", handlePipeError);
|
|
457
|
+
zstd.stdin.on("error", handlePipeError);
|
|
436
458
|
tar.on("close", (code) => {
|
|
437
459
|
tarCode = code;
|
|
438
460
|
finish();
|
|
@@ -1378,6 +1400,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
|
|
|
1378
1400
|
artifactsPath: `${getJobDir(current.id)}/artifacts`,
|
|
1379
1401
|
responsePreview,
|
|
1380
1402
|
responseAvailable,
|
|
1403
|
+
heartbeatStaleMs: ORACLE_STALE_HEARTBEAT_MS,
|
|
1381
1404
|
}),
|
|
1382
1405
|
},
|
|
1383
1406
|
],
|
|
@@ -1419,7 +1442,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
|
|
|
1419
1442
|
}
|
|
1420
1443
|
if (ctx.hasUI) refreshOracleStatus(ctx);
|
|
1421
1444
|
return {
|
|
1422
|
-
content: [{ type: "text", text: cancelled
|
|
1445
|
+
content: [{ type: "text", text: formatOracleCancelOutcome(cancelled) }],
|
|
1423
1446
|
details: { job: redactJobDetails(cancelled, { queue: buildOracleQueueSnapshot(cancelled, cancelled.status === "queued" ? getQueuePosition(cancelled.id) : undefined) }) },
|
|
1424
1447
|
};
|
|
1425
1448
|
} catch (error) {
|
|
@@ -34,6 +34,8 @@ export interface OracleLifecycleTrackedJobLike {
|
|
|
34
34
|
submittedAt?: string;
|
|
35
35
|
completedAt?: string;
|
|
36
36
|
heartbeatAt?: string;
|
|
37
|
+
cancelRequestedAt?: string;
|
|
38
|
+
cancelReason?: string;
|
|
37
39
|
lifecycleEvents?: OracleJobLifecycleEvent[];
|
|
38
40
|
cleanupPending?: boolean;
|
|
39
41
|
cleanupWarnings?: string[];
|
|
@@ -167,7 +167,11 @@ export function transitionOracleJobPhase(job, phase, options = {}) {
|
|
|
167
167
|
? { submittedAt: patch.submittedAt ?? job.submittedAt ?? at }
|
|
168
168
|
: {}),
|
|
169
169
|
...(TERMINAL_ORACLE_JOB_STATUSES.includes(status)
|
|
170
|
-
? {
|
|
170
|
+
? {
|
|
171
|
+
completedAt: patch.completedAt ?? job.completedAt ?? at,
|
|
172
|
+
cancelRequestedAt: undefined,
|
|
173
|
+
cancelReason: undefined,
|
|
174
|
+
}
|
|
171
175
|
: { completedAt: patch.completedAt ?? job.completedAt }),
|
|
172
176
|
...(options.clearNotificationClaim
|
|
173
177
|
? { notifyClaimedAt: undefined, notifyClaimedBy: undefined }
|
|
@@ -8,6 +8,7 @@ export interface OracleJobSummaryLike {
|
|
|
8
8
|
queuedAt?: string;
|
|
9
9
|
submittedAt?: string;
|
|
10
10
|
completedAt?: string;
|
|
11
|
+
heartbeatAt?: string;
|
|
11
12
|
projectId: string;
|
|
12
13
|
sessionId: string;
|
|
13
14
|
followUpToJobId?: string;
|
|
@@ -35,6 +36,8 @@ export interface OracleJobSummaryOptions {
|
|
|
35
36
|
responseAvailable?: boolean;
|
|
36
37
|
includeLatestEvent?: boolean;
|
|
37
38
|
includeWorkerLogPath?: boolean;
|
|
39
|
+
nowMs?: number;
|
|
40
|
+
heartbeatStaleMs?: number;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export interface OracleSubmitResponseOptions {
|
|
@@ -51,6 +54,7 @@ export interface OracleStatusCounts {
|
|
|
51
54
|
|
|
52
55
|
export declare function formatBytes(bytes: number): string;
|
|
53
56
|
export declare function formatOracleLifecycleEvent(event: OracleJobLifecycleEvent | undefined): string | undefined;
|
|
57
|
+
export declare function formatOracleCancelOutcome(job: { id: string; status: string }): string;
|
|
54
58
|
export declare function formatOracleJobSummary(job: OracleJobSummaryLike, options?: OracleJobSummaryOptions): string;
|
|
55
59
|
export declare function buildOracleWakeupNotificationContent(
|
|
56
60
|
job: OracleJobSummaryLike,
|
|
@@ -46,6 +46,69 @@ function formatAutoPrunedArchiveMessage(autoPrunedPrefixes) {
|
|
|
46
46
|
return `Archive auto-pruned generic generated-output-name dirs to fit size limit: ${autoPrunedPrefixes.map((entry) => `${entry.relativePath}/ (${formatBytes(entry.bytes)})`).join(", ")}`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
const ACTIVE_SUMMARY_STATUSES = new Set(["preparing", "submitted", "waiting"]);
|
|
50
|
+
const DEFAULT_ORACLE_HEARTBEAT_STALE_MS = 3 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string | undefined} value
|
|
54
|
+
* @returns {number | undefined}
|
|
55
|
+
*/
|
|
56
|
+
function parseTimestamp(value) {
|
|
57
|
+
if (!value) return undefined;
|
|
58
|
+
const ms = Date.parse(value);
|
|
59
|
+
return Number.isNaN(ms) ? undefined : ms;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {number} elapsedMs
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
function formatElapsed(elapsedMs) {
|
|
67
|
+
const totalSeconds = Math.max(0, Math.round(elapsedMs / 1000));
|
|
68
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
69
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
70
|
+
const seconds = totalSeconds % 60;
|
|
71
|
+
if (totalMinutes < 60) return seconds === 0 ? `${totalMinutes}m` : `${totalMinutes}m ${String(seconds).padStart(2, "0")}s`;
|
|
72
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
73
|
+
const minutes = totalMinutes % 60;
|
|
74
|
+
return minutes === 0 ? `${hours}h` : `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {OracleJobSummaryLike} job
|
|
79
|
+
* @param {OracleJobSummaryOptions} [options]
|
|
80
|
+
* @returns {string | undefined}
|
|
81
|
+
*/
|
|
82
|
+
function formatHeartbeatFreshness(job, options = {}) {
|
|
83
|
+
if (!ACTIVE_SUMMARY_STATUSES.has(job.status)) return undefined;
|
|
84
|
+
const nowMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
|
|
85
|
+
const staleMs = Number.isFinite(options.heartbeatStaleMs) ? options.heartbeatStaleMs : DEFAULT_ORACLE_HEARTBEAT_STALE_MS;
|
|
86
|
+
const heartbeatMs = parseTimestamp(job.heartbeatAt);
|
|
87
|
+
if (heartbeatMs !== undefined) {
|
|
88
|
+
const elapsedMs = Math.max(0, nowMs - heartbeatMs);
|
|
89
|
+
return `heartbeat: ${elapsedMs > staleMs ? "likely stale" : "fresh"} (${formatElapsed(elapsedMs)} ago)`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const submittedMs = parseTimestamp(job.submittedAt);
|
|
93
|
+
const createdMs = parseTimestamp(job.createdAt);
|
|
94
|
+
const baselineMs = submittedMs ?? createdMs;
|
|
95
|
+
if (baselineMs === undefined) return "heartbeat: unavailable";
|
|
96
|
+
|
|
97
|
+
const elapsedMs = Math.max(0, nowMs - baselineMs);
|
|
98
|
+
const freshness = elapsedMs > staleMs ? "waiting for first worker update; likely stale" : "waiting for first worker update";
|
|
99
|
+
return `heartbeat: ${freshness} (${formatElapsed(elapsedMs)} ${submittedMs !== undefined ? "since submit" : "since create"})`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {{ id: string; status: string }} job
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
export function formatOracleCancelOutcome(job) {
|
|
107
|
+
if (job.status === "cancelled") return `Cancelled oracle job ${job.id}.`;
|
|
108
|
+
if (job.status === "failed") return `Oracle job ${job.id} failed during cancellation.`;
|
|
109
|
+
return `Oracle job ${job.id} finished as ${job.status} before cancellation completed.`;
|
|
110
|
+
}
|
|
111
|
+
|
|
49
112
|
/**
|
|
50
113
|
* @param {OracleJobSummaryLike} job
|
|
51
114
|
* @param {OracleJobSummaryOptions} [options]
|
|
@@ -78,6 +141,7 @@ export function formatOracleJobSummary(job, options = {}) {
|
|
|
78
141
|
options.queuePosition ? `queue-position: ${options.queuePosition.position} of ${options.queuePosition.depth} global` : undefined,
|
|
79
142
|
`project: ${job.projectId}`,
|
|
80
143
|
`session: ${job.sessionId}`,
|
|
144
|
+
formatHeartbeatFreshness(job, options),
|
|
81
145
|
job.completedAt ? `completed: ${job.completedAt}` : undefined,
|
|
82
146
|
job.followUpToJobId ? `follow-up-to: ${job.followUpToJobId}` : undefined,
|
|
83
147
|
job.chatUrl ? `chat: ${job.chatUrl}` : undefined,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-oracle",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "ChatGPT web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -62,18 +62,19 @@
|
|
|
62
62
|
"basic-ftp": "^5.2.2"
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
|
-
"@mariozechner/pi-ai": "^0.
|
|
66
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
65
|
+
"@mariozechner/pi-ai": "^0.67.2",
|
|
66
|
+
"@mariozechner/pi-coding-agent": "^0.67.2",
|
|
67
67
|
"@sinclair/typebox": "^0.34.49",
|
|
68
|
-
"@types/node": "^
|
|
69
|
-
"esbuild": "^0.
|
|
70
|
-
"tsx": "^4.
|
|
71
|
-
"typescript": "^
|
|
68
|
+
"@types/node": "^25.6.0",
|
|
69
|
+
"esbuild": "^0.28.0",
|
|
70
|
+
"tsx": "^4.21.0",
|
|
71
|
+
"typescript": "^6.0.2"
|
|
72
72
|
},
|
|
73
73
|
"engines": {
|
|
74
74
|
"node": ">=22"
|
|
75
75
|
},
|
|
76
76
|
"os": [
|
|
77
77
|
"darwin"
|
|
78
|
-
]
|
|
78
|
+
],
|
|
79
|
+
"packageManager": "npm@11.12.1"
|
|
79
80
|
}
|