@awak-app/simy-cli 0.7.16 → 0.7.18
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/README.md +180 -26
- package/package.json +3 -2
- package/src/agent.js +128 -7
- package/src/bounded-local-task-subtask-pool.js +32 -6
- package/src/bounded-local-task-subtasks-contract.js +1 -1
- package/src/cli-contract.js +1 -1
- package/src/codex-plugins/simy/.codex-plugin/plugin.json +2 -2
- package/src/codex-plugins/simy/hooks/README.md +8 -3
- package/src/codex-plugins/simy/hooks/delivery-loop.mjs +261 -67
- package/src/codex-plugins/simy/hooks/hooks.json +1 -1
- package/src/codex-plugins/simy/hooks/loop-kernel/conformance.json +136 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/index.mjs +42 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/outcome-review.mjs +31 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/pin.json +6 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/policy.json +141 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/task-pool.mjs +34 -0
- package/src/codex-plugins/simy/hooks/loop-kernel/work-frontier.mjs +132 -0
- package/src/codex-plugins/simy/hooks/loop-kernel.mjs +6 -0
- package/src/codex-plugins/simy/hooks/outcome-progress.mjs +15 -3
- package/src/codex-plugins/simy/hooks/purpose-completion-contract.mjs +10 -0
- package/src/codex-plugins/simy/hooks/purpose-evidence.mjs +8 -6
- package/src/codex-plugins/simy/hooks/result-cycle.mjs +32 -6
- package/src/codex-plugins/simy/hooks/work-frontier.mjs +2 -0
- package/src/codex-plugins/simy/skills/diagnose/SKILL.md +10 -4
- package/src/coding-run-recovery.js +4 -2
- package/src/delivery-hooks/claude-workflow.md +10 -7
- package/src/delivery-hooks/command.js +45 -10
- package/src/delivery-hooks/consult.js +60 -0
- package/src/delivery-hooks/diagnosis.js +2 -2
- package/src/delivery-hooks/legacy-hook-routing.js +46 -8
- package/src/delivery-hooks/local-runtime.js +50 -1
- package/src/delivery-hooks/local-supervisor.js +1 -1
- package/src/delivery-hooks/repair-continuation.js +80 -0
- package/src/delivery-hooks/repair.js +164 -0
- package/src/delivery-hooks/supervisor-service.js +3 -1
- package/src/delivery-hooks/supervisor.js +88 -12
- package/src/desktop-access.js +2 -1
- package/src/desktop-host.js +62 -2
- package/src/durable-local-task-worker.js +372 -58
- package/src/execution-capability-contract.js +15 -1
- package/src/index.js +3 -3
- package/src/local-photos-read.swift +33 -0
- package/src/local-photos.js +47 -0
- package/src/local-task-attachment-store.js +31 -0
- package/src/local-task-destination.js +174 -0
- package/src/local-task.js +119 -7
- package/src/native-delegation-contract.js +68 -0
- package/src/native-provider-identity.js +60 -0
- package/src/orchestrator/budget.js +22 -11
- package/src/orchestrator/independent-audit.js +9 -18
- package/src/orchestrator/loop.js +27 -59
- package/src/orchestrator/recovery.js +32 -2
- package/src/provider-delegation.js +336 -0
- package/src/runner.js +2 -1
- package/src/session-supervision/artifact-observer.js +138 -0
- package/src/session-supervision/claude-sessions.js +5 -3
- package/src/session-supervision/codex-app-server.js +20 -5
- package/src/session-supervision/completion-artifacts.js +44 -0
- package/src/session-supervision/consistency-auditor.js +8 -11
- package/src/session-supervision/continuation-analysis.js +46 -28
- package/src/session-supervision/discovery-window.js +1 -1
- package/src/session-supervision/final-response-recovery.js +5 -3
- package/src/session-supervision/index.js +25 -8
- package/src/session-supervision/purpose-recovery.js +12 -3
- package/src/session-supervision/purpose-verification.js +9 -57
- package/src/session-supervision/recovery-frontier.js +55 -0
- package/src/session-supervision/remote-hosts.js +1 -1
- package/src/session-supervision/stop-notifications.js +8 -8
- package/src/sqm/agent-skill.js +1 -1
- package/src/sqm/checkers.js +12 -6
- package/src/sqm/claude-plugin-update.js +22 -8
- package/src/sqm/claude-plugin.js +4 -0
- package/src/sqm/local-tests.js +2 -2
- package/src/sqm/plugin-cache.js +67 -0
- package/src/sqm/proof.js +25 -0
- package/src/sqm/report.js +1 -0
- package/src/sqm/workflow-onboarding.js +3 -39
- package/src/sqm/workflow-update.js +17 -6
- package/src/team-status-fence-store.js +32 -0
- package/src/team-threads.js +478 -0
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readLocalPhotoSource } from "./local-photos.js";
|
|
2
|
+
import { normalizeLocalTaskDestination, resolveLocalTaskDestination, saveLocalTaskDestination } from "./local-task-destination.js";
|
|
3
|
+
import { nativeDelegationFromClaim, nativeDelegationResult, nativeContinuationInstruction, nativeDelegationFailureResult } from './native-delegation-contract.js';
|
|
1
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
5
|
import { execFile } from "node:child_process";
|
|
3
6
|
import { constants as fsConstants } from "node:fs";
|
|
@@ -33,6 +36,7 @@ import {
|
|
|
33
36
|
import {
|
|
34
37
|
LOCAL_TASK_FILE_CAPABILITY_VERSION,
|
|
35
38
|
validateLocalTaskFile,
|
|
39
|
+
validateLocalTaskFileCollection,
|
|
36
40
|
} from "./local-task-file-capabilities.js";
|
|
37
41
|
import {
|
|
38
42
|
normalizeLocalTaskScenarioPack,
|
|
@@ -51,6 +55,7 @@ import {
|
|
|
51
55
|
} from "./durable-local-task-steps-contract.js";
|
|
52
56
|
import {
|
|
53
57
|
normalizeDurableLocalTaskExecutionClaimV2,
|
|
58
|
+
normalizeParentUploadedRef,
|
|
54
59
|
} from "./bounded-local-task-subtasks-contract.js";
|
|
55
60
|
import {
|
|
56
61
|
createBoundedLocalTaskSubtaskPool,
|
|
@@ -98,6 +103,7 @@ export function createDurableLocalTaskApi({
|
|
|
98
103
|
requestTimeoutMs = REQUEST_TIMEOUT_MS,
|
|
99
104
|
artifactUploadTimeoutMs = ARTIFACT_UPLOAD_TIMEOUT_MS,
|
|
100
105
|
} = {}) {
|
|
106
|
+
let narrativeImageSupported = true;
|
|
101
107
|
if (typeof getConnection !== "function") {
|
|
102
108
|
throw new TypeError("getConnection is required");
|
|
103
109
|
}
|
|
@@ -127,7 +133,7 @@ export function createDurableLocalTaskApi({
|
|
|
127
133
|
method: "POST",
|
|
128
134
|
headers: webApiHeaders(
|
|
129
135
|
{ token: connection.token },
|
|
130
|
-
{ "Content-Type": "application/json" },
|
|
136
|
+
{ "Content-Type": "application/json", "X-SIMY-Native-Delegation": "provider_delegation_result.v1", "X-SIMY-Device-IO": "local_device_io.v1" },
|
|
131
137
|
),
|
|
132
138
|
body: JSON.stringify(body),
|
|
133
139
|
signal: AbortSignal.timeout(requestTimeoutMs),
|
|
@@ -193,14 +199,22 @@ export function createDurableLocalTaskApi({
|
|
|
193
199
|
worker_id: workerId,
|
|
194
200
|
lease_seconds: leaseSeconds,
|
|
195
201
|
turn_id: turnId,
|
|
196
|
-
fp_narrative_image: narrativeImageCapabilityMetadata(),
|
|
202
|
+
...(narrativeImageSupported ? { fp_narrative_image: narrativeImageCapabilityMetadata() } : {}),
|
|
197
203
|
};
|
|
198
204
|
if (controlledPlan !== null) body.controlled_plan = controlledPlan;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
body,
|
|
202
|
-
|
|
203
|
-
|
|
205
|
+
let payload;
|
|
206
|
+
try {
|
|
207
|
+
payload = await request("local-tasks/claim", body, { ambiguousOnTransport: controlledPlan !== null });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
// Older gateways reject unknown fields before admission. Only that exact
|
|
210
|
+
// validation rejection permits a retry; uncertain claims never replay.
|
|
211
|
+
if (!narrativeImageSupported || error.status !== 400 ||
|
|
212
|
+
error.code !== "invalid_local_task_worker_request" ||
|
|
213
|
+
error.message !== "unsupported request field: fp_narrative_image") throw error;
|
|
214
|
+
narrativeImageSupported = false;
|
|
215
|
+
delete body.fp_narrative_image;
|
|
216
|
+
payload = await request("local-tasks/claim", body, { ambiguousOnTransport: controlledPlan !== null });
|
|
217
|
+
}
|
|
204
218
|
if (isLocalTaskPlanOrchestration(payload)) return payload;
|
|
205
219
|
return payload?.claim ?? null;
|
|
206
220
|
},
|
|
@@ -242,6 +256,7 @@ export function createDurableLocalTaskApi({
|
|
|
242
256
|
resultSummary = null,
|
|
243
257
|
artifactRefs = [],
|
|
244
258
|
failureCode = null,
|
|
259
|
+
delegationResult = null,
|
|
245
260
|
}) {
|
|
246
261
|
const payload = await request(
|
|
247
262
|
`local-tasks/${encodeURIComponent(turnId)}/status`,
|
|
@@ -252,10 +267,11 @@ export function createDurableLocalTaskApi({
|
|
|
252
267
|
result_summary: resultSummary,
|
|
253
268
|
artifact_refs: artifactRefs,
|
|
254
269
|
failure_code: failureCode,
|
|
270
|
+
...(delegationResult ? { delegation_result: delegationResult } : {}),
|
|
255
271
|
},
|
|
256
272
|
{ ambiguousOnTransport: true },
|
|
257
273
|
);
|
|
258
|
-
return payload?.ok === true;
|
|
274
|
+
return payload?.ok === true && (!delegationResult || payload?.delegation_result_accepted === true);
|
|
259
275
|
},
|
|
260
276
|
async prepareArtifact({
|
|
261
277
|
turnId,
|
|
@@ -277,7 +293,8 @@ export function createDurableLocalTaskApi({
|
|
|
277
293
|
mime_type: mimeType,
|
|
278
294
|
size_bytes: sizeBytes,
|
|
279
295
|
sha256,
|
|
280
|
-
...(
|
|
296
|
+
...(workerId !== null ? { worker_id: workerId, lease_epoch: leaseEpoch } : {}),
|
|
297
|
+
...(nativeImageReceipt ? { native_image_receipt: nativeImageReceipt } : {}),
|
|
281
298
|
},
|
|
282
299
|
{ ambiguousOnTransport: true },
|
|
283
300
|
);
|
|
@@ -338,7 +355,8 @@ export function createDurableLocalTaskApi({
|
|
|
338
355
|
local_artifact_id: localArtifactId,
|
|
339
356
|
sha256,
|
|
340
357
|
size_bytes: sizeBytes,
|
|
341
|
-
...(
|
|
358
|
+
...(workerId !== null ? { worker_id: workerId, lease_epoch: leaseEpoch } : {}),
|
|
359
|
+
...(nativeImageReceipt ? { native_image_receipt: nativeImageReceipt } : {}),
|
|
342
360
|
},
|
|
343
361
|
{ ambiguousOnTransport: true },
|
|
344
362
|
);
|
|
@@ -476,6 +494,28 @@ export function createDurableLocalTaskApi({
|
|
|
476
494
|
);
|
|
477
495
|
return payload;
|
|
478
496
|
},
|
|
497
|
+
async download_local_task_source_v2({ turnId, workerId, leaseEpoch, source }) {
|
|
498
|
+
const payload = await request(`local-tasks/${encodeURIComponent(turnId)}/sources/download`, {
|
|
499
|
+
worker_id: workerId, lease_epoch: leaseEpoch,
|
|
500
|
+
source_id: source.source_id, upload_id: source.upload_id, sha256: source.sha256,
|
|
501
|
+
});
|
|
502
|
+
const prepared = validateSubtaskSourceDownload(payload, source, 'local_task_source_download.v2');
|
|
503
|
+
const response = await fetchImpl(prepared.url, {
|
|
504
|
+
method: 'GET', headers: prepared.headers, redirect: 'error',
|
|
505
|
+
signal: AbortSignal.timeout(artifactUploadTimeoutMs),
|
|
506
|
+
});
|
|
507
|
+
if (!response.ok) throw new DurableLocalTaskApiError('SIMY could not download the authorized source.',
|
|
508
|
+
{ status: response.status, code: 'local_task_source_download_failed' });
|
|
509
|
+
const maxBytes = Number(source.size_bytes);
|
|
510
|
+
// Bound streamed bytes even if storage misreports Content-Length.
|
|
511
|
+
const chunks = []; let length = 0;
|
|
512
|
+
for await (const chunk of response.body ?? []) {
|
|
513
|
+
length += chunk.length;
|
|
514
|
+
if (length > maxBytes) throw new DurableLocalTaskApiError('Source size changed.', { code: 'local_task_source_integrity_failed' });
|
|
515
|
+
chunks.push(Buffer.from(chunk));
|
|
516
|
+
}
|
|
517
|
+
return Buffer.concat(chunks);
|
|
518
|
+
},
|
|
479
519
|
async download_local_task_subtask_source_v2({
|
|
480
520
|
turnId,
|
|
481
521
|
batchId,
|
|
@@ -573,7 +613,7 @@ function boundedSubtaskFenceBody(input) {
|
|
|
573
613
|
};
|
|
574
614
|
}
|
|
575
615
|
|
|
576
|
-
function validateSubtaskSourceDownload(value, expected) {
|
|
616
|
+
function validateSubtaskSourceDownload(value, expected, schema = "local_task_subtask_source_download.v2") {
|
|
577
617
|
const topKeys = new Set(["schema_version", "source", "download"]);
|
|
578
618
|
const sourceKeys = new Set([
|
|
579
619
|
"source_id",
|
|
@@ -594,7 +634,7 @@ function validateSubtaskSourceDownload(value, expected) {
|
|
|
594
634
|
Array.isArray(value) ||
|
|
595
635
|
Object.keys(value).some((key) => !topKeys.has(key)) ||
|
|
596
636
|
Object.keys(value).length !== topKeys.size ||
|
|
597
|
-
value.schema_version !==
|
|
637
|
+
value.schema_version !== schema ||
|
|
598
638
|
!value.source ||
|
|
599
639
|
typeof value.source !== "object" ||
|
|
600
640
|
Array.isArray(value.source) ||
|
|
@@ -639,10 +679,13 @@ export function createDurableLocalTaskWorker({
|
|
|
639
679
|
materializeArtifacts = materializePipelineArtifacts,
|
|
640
680
|
attachmentStore = null,
|
|
641
681
|
getDeviceId = () => null,
|
|
682
|
+
readPhotoSource = readLocalPhotoSource,
|
|
642
683
|
beginWork = () => () => {},
|
|
643
684
|
workerId = `simy-cli-${randomUUID()}`,
|
|
644
685
|
leaseSeconds = DEFAULT_LEASE_SECONDS,
|
|
686
|
+
enableBoundedSubtasks = true,
|
|
645
687
|
heartbeatIntervalMs = DEFAULT_HEARTBEAT_INTERVAL_MS,
|
|
688
|
+
now = Date.now,
|
|
646
689
|
setIntervalImpl = setInterval,
|
|
647
690
|
clearIntervalImpl = clearInterval,
|
|
648
691
|
onError = () => {},
|
|
@@ -689,6 +732,7 @@ export function createDurableLocalTaskWorker({
|
|
|
689
732
|
});
|
|
690
733
|
task.durable_turn_id = runtime.parent_turn_id;
|
|
691
734
|
task.durable_step_id = runtime.step_id;
|
|
735
|
+
task.durable_subtask_attempt_id = runtime.attempt_id;
|
|
692
736
|
await persist(task);
|
|
693
737
|
registry.create(task);
|
|
694
738
|
boundedTasks.set(runtime.attempt_id, task);
|
|
@@ -734,6 +778,8 @@ export function createDurableLocalTaskWorker({
|
|
|
734
778
|
let knownLeaseExpiry = Number.NaN;
|
|
735
779
|
let boundary = "not_started";
|
|
736
780
|
let leaseLost = false;
|
|
781
|
+
let resolveLeaseLoss;
|
|
782
|
+
const leaseLoss = new Promise(resolve => { resolveLeaseLoss = resolve; });
|
|
737
783
|
let cancelRequested = false;
|
|
738
784
|
let normalized = null;
|
|
739
785
|
const executionStartedAt = Date.now();
|
|
@@ -748,13 +794,21 @@ export function createDurableLocalTaskWorker({
|
|
|
748
794
|
"local_task_step_lease_lost",
|
|
749
795
|
),
|
|
750
796
|
);
|
|
751
|
-
if (task)
|
|
797
|
+
if (task) {
|
|
798
|
+
task.reason_code ||= "local_task_lease_lost";
|
|
799
|
+
await stopTask(task).catch(() => undefined);
|
|
800
|
+
}
|
|
801
|
+
resolveLeaseLoss();
|
|
752
802
|
};
|
|
753
803
|
|
|
754
804
|
try {
|
|
755
805
|
normalized = normalizeDurableLocalTaskStepClaim(claim, {
|
|
806
|
+
nowMs: now(),
|
|
756
807
|
leaseSeconds,
|
|
757
808
|
});
|
|
809
|
+
if (claim.execution_spec?.local_destination !== undefined || claim.execution_spec?.local_photo_source !== undefined) {
|
|
810
|
+
throw workerError("Local destination delivery is not supported for stepped execution.", "local_task_destination_unconfirmed");
|
|
811
|
+
}
|
|
758
812
|
if (normalized.disposition !== "execute") {
|
|
759
813
|
throw workerError(
|
|
760
814
|
normalized.disposition === "approval_required"
|
|
@@ -813,6 +867,16 @@ export function createDurableLocalTaskWorker({
|
|
|
813
867
|
registry.create(task);
|
|
814
868
|
if (active) active.task = task;
|
|
815
869
|
|
|
870
|
+
const checkLease = async () => {
|
|
871
|
+
if (!leaseLost && now() >= knownLeaseExpiry) {
|
|
872
|
+
await stopForLeaseLoss(workerError(
|
|
873
|
+
"The local execution lease expired while waiting for SIMY Web.",
|
|
874
|
+
"local_task_lease_expired",
|
|
875
|
+
));
|
|
876
|
+
}
|
|
877
|
+
if (leaseLost) await leaseLoss;
|
|
878
|
+
return !leaseLost;
|
|
879
|
+
};
|
|
816
880
|
const heartbeat = async () => {
|
|
817
881
|
if (
|
|
818
882
|
!active ||
|
|
@@ -821,6 +885,8 @@ export function createDurableLocalTaskWorker({
|
|
|
821
885
|
) {
|
|
822
886
|
return;
|
|
823
887
|
}
|
|
888
|
+
const renewalStartedAt = now();
|
|
889
|
+
if (!await checkLease()) return;
|
|
824
890
|
try {
|
|
825
891
|
const response = await api.heartbeatStep({
|
|
826
892
|
turnId: normalized.turnId,
|
|
@@ -829,6 +895,7 @@ export function createDurableLocalTaskWorker({
|
|
|
829
895
|
leaseEpoch: normalized.leaseEpoch,
|
|
830
896
|
leaseSeconds,
|
|
831
897
|
});
|
|
898
|
+
if (!await checkLease()) return;
|
|
832
899
|
if (response.ok !== true) {
|
|
833
900
|
await stopForLeaseLoss(
|
|
834
901
|
workerError(
|
|
@@ -838,7 +905,7 @@ export function createDurableLocalTaskWorker({
|
|
|
838
905
|
);
|
|
839
906
|
return;
|
|
840
907
|
}
|
|
841
|
-
knownLeaseExpiry =
|
|
908
|
+
knownLeaseExpiry = renewalStartedAt + leaseSeconds * 1_000;
|
|
842
909
|
if (response.cancelRequested) {
|
|
843
910
|
cancelRequested = true;
|
|
844
911
|
await stopTask(task);
|
|
@@ -860,7 +927,7 @@ export function createDurableLocalTaskWorker({
|
|
|
860
927
|
);
|
|
861
928
|
if (
|
|
862
929
|
Number.isFinite(knownLeaseExpiry) &&
|
|
863
|
-
|
|
930
|
+
now() + heartbeatIntervalMs >= knownLeaseExpiry
|
|
864
931
|
) {
|
|
865
932
|
await stopForLeaseLoss(error);
|
|
866
933
|
}
|
|
@@ -872,16 +939,18 @@ export function createDurableLocalTaskWorker({
|
|
|
872
939
|
heartbeatInFlight = null;
|
|
873
940
|
});
|
|
874
941
|
}
|
|
875
|
-
return heartbeatInFlight;
|
|
942
|
+
return Promise.race([heartbeatInFlight, leaseLoss]);
|
|
876
943
|
};
|
|
877
944
|
|
|
878
945
|
heartbeatTimer = setIntervalImpl(() => {
|
|
879
|
-
|
|
946
|
+
// Expiry must be checked even while a heartbeat request is still pending.
|
|
947
|
+
void checkLease().then((valid) => { if (valid) void runHeartbeat(); });
|
|
880
948
|
}, heartbeatIntervalMs);
|
|
881
949
|
heartbeatTimer?.unref?.();
|
|
882
950
|
|
|
883
951
|
const completed = await runLocalTask(task, {
|
|
884
952
|
beforeProviderInvocation: async () => {
|
|
953
|
+
await checkLease();
|
|
885
954
|
if (leaseLost || cancelRequested || closed) {
|
|
886
955
|
throw workerError(
|
|
887
956
|
"The durable step was stopped before the Provider started.",
|
|
@@ -904,8 +973,13 @@ export function createDurableLocalTaskWorker({
|
|
|
904
973
|
},
|
|
905
974
|
);
|
|
906
975
|
boundary = "started";
|
|
976
|
+
// The server ACK can arrive after cancellation or lease expiry.
|
|
977
|
+
if (!await checkLease() || cancelRequested || closed) {
|
|
978
|
+
throw workerError("The execution authority expired before Provider dispatch.",
|
|
979
|
+
"local_task_cancelled_before_invocation");
|
|
980
|
+
}
|
|
907
981
|
} catch (error) {
|
|
908
|
-
boundary = error?.ambiguous ? "unknown" : "not_started";
|
|
982
|
+
boundary = boundary === "started" || error?.ambiguous ? "unknown" : "not_started";
|
|
909
983
|
throw workerError(
|
|
910
984
|
error?.ambiguous
|
|
911
985
|
? "SIMY could not confirm the durable step Provider boundary. This step will not be started again."
|
|
@@ -919,12 +993,13 @@ export function createDurableLocalTaskWorker({
|
|
|
919
993
|
},
|
|
920
994
|
});
|
|
921
995
|
|
|
996
|
+
if (!await checkLease()) return;
|
|
997
|
+
await runHeartbeat();
|
|
998
|
+
if (leaseLost) return;
|
|
922
999
|
if (heartbeatTimer) {
|
|
923
1000
|
clearIntervalImpl(heartbeatTimer);
|
|
924
1001
|
heartbeatTimer = null;
|
|
925
1002
|
}
|
|
926
|
-
await runHeartbeat();
|
|
927
|
-
if (leaseLost) return;
|
|
928
1003
|
const status = completed.status === "stopped"
|
|
929
1004
|
? "cancelled"
|
|
930
1005
|
: boundary === "unknown"
|
|
@@ -1090,6 +1165,8 @@ export function createDurableLocalTaskWorker({
|
|
|
1090
1165
|
let knownLeaseExpiry = Number.NaN;
|
|
1091
1166
|
let boundary = "not_started";
|
|
1092
1167
|
let leaseLost = false;
|
|
1168
|
+
let resolveLeaseLoss;
|
|
1169
|
+
const leaseLoss = new Promise(resolve => { resolveLeaseLoss = resolve; });
|
|
1093
1170
|
let cancelRequested = false;
|
|
1094
1171
|
let materializedAttachmentRefs = null;
|
|
1095
1172
|
|
|
@@ -1103,12 +1180,22 @@ export function createDurableLocalTaskWorker({
|
|
|
1103
1180
|
"local_task_lease_lost",
|
|
1104
1181
|
),
|
|
1105
1182
|
);
|
|
1106
|
-
if (task)
|
|
1183
|
+
if (task) {
|
|
1184
|
+
task.reason_code ||= "local_task_lease_lost";
|
|
1185
|
+
await stopTask(task).catch(() => undefined);
|
|
1186
|
+
}
|
|
1187
|
+
resolveLeaseLoss();
|
|
1107
1188
|
};
|
|
1108
1189
|
|
|
1109
1190
|
try {
|
|
1110
|
-
const normalized = normalizeClaim(claim, { leaseSeconds });
|
|
1191
|
+
const normalized = normalizeClaim(claim, { leaseSeconds, nowMs: now() });
|
|
1192
|
+
const native = nativeDelegationFromClaim(claim, deviceId);
|
|
1111
1193
|
knownLeaseExpiry = normalized.leaseExpiresAtMs;
|
|
1194
|
+
const requestedDestination = normalizeLocalTaskDestination(normalized.spec.local_destination);
|
|
1195
|
+
if (requestedDestination && requestedDestination.device_id !== deviceId) throw workerError("The destination belongs to another device.", "local_task_destination_unconfirmed");
|
|
1196
|
+
if (requestedDestination?.authorized_root && normalized.spec.delegation_target?.provider !== "codex") throw workerError("This folder requires an existing native workspace authorization.", "local_task_destination_unconfirmed");
|
|
1197
|
+
let destination = requestedDestination && !requestedDestination.authorized_root ? await resolveLocalTaskDestination(normalized.spec, { deviceId, resolveRepository }) : null;
|
|
1198
|
+
const photoContext = normalized.spec.local_photo_source ? await readPhotoSource(normalized.spec.local_photo_source, { deviceId }) : null;
|
|
1112
1199
|
const workspace = await resolveWorkspace(normalized, {
|
|
1113
1200
|
storageRoot,
|
|
1114
1201
|
workspaceRoot,
|
|
@@ -1116,6 +1203,12 @@ export function createDurableLocalTaskWorker({
|
|
|
1116
1203
|
resolveRepository,
|
|
1117
1204
|
});
|
|
1118
1205
|
let attachments = [];
|
|
1206
|
+
if (normalized.uploadedRefs.length) {
|
|
1207
|
+
if (!native || typeof api.download_local_task_source_v2 !== 'function')
|
|
1208
|
+
throw workerError('Native uploaded source download is unavailable.', 'local_task_source_download_unavailable');
|
|
1209
|
+
attachments = await materializeNativeUploadedInputs({ refs: normalized.uploadedRefs, inputsRoot: workspace.inputs_root,
|
|
1210
|
+
download: source => api.download_local_task_source_v2({ turnId: normalized.turnId, workerId, leaseEpoch: normalized.leaseEpoch, source }) });
|
|
1211
|
+
}
|
|
1119
1212
|
if (normalized.attachmentRefs.length > 0) {
|
|
1120
1213
|
if (!attachmentStore || typeof attachmentStore.materialize !== "function") {
|
|
1121
1214
|
throw workerError(
|
|
@@ -1123,11 +1216,18 @@ export function createDurableLocalTaskWorker({
|
|
|
1123
1216
|
"durable_local_task_attachments_unavailable",
|
|
1124
1217
|
);
|
|
1125
1218
|
}
|
|
1126
|
-
|
|
1127
|
-
refs: normalized.attachmentRefs,
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1219
|
+
try {
|
|
1220
|
+
attachments.push(...await attachmentStore.materialize({ refs: normalized.attachmentRefs, deviceId, inputsRoot: workspace.inputs_root }));
|
|
1221
|
+
} catch (error) {
|
|
1222
|
+
const parent = native && claim.execution_spec.continuation?.parent_turn_id;
|
|
1223
|
+
const retained = parent && registry.list().find(prior => prior.durable_turn_id === parent &&
|
|
1224
|
+
prior.native_identity?.device_id === deviceId && prior.native_identity?.account_id === native.nativeIdentity.account_id &&
|
|
1225
|
+
prior.delegation?.origin_conversation_id === claim.thread_id);
|
|
1226
|
+
if (!retained || !['ENOENT', 'local_task_attachment_ref_not_found'].includes(error.code) ||
|
|
1227
|
+
typeof attachmentStore.materializeRetained !== 'function') throw error;
|
|
1228
|
+
attachments.push(...await attachmentStore.materializeRetained({ refs: normalized.attachmentRefs, deviceId,
|
|
1229
|
+
inputsRoot: workspace.inputs_root, retainedTask: retained }));
|
|
1230
|
+
}
|
|
1131
1231
|
materializedAttachmentRefs = normalized.attachmentRefs;
|
|
1132
1232
|
}
|
|
1133
1233
|
validateLocalTaskScenarioInputs(normalized.scenarioPack, {
|
|
@@ -1136,9 +1236,10 @@ export function createDurableLocalTaskWorker({
|
|
|
1136
1236
|
});
|
|
1137
1237
|
task = createLocalTask({
|
|
1138
1238
|
id: workspace.localTaskId,
|
|
1139
|
-
instruction: normalized.instruction,
|
|
1239
|
+
instruction: native ? nativeContinuationInstruction(claim) : normalized.instruction,
|
|
1140
1240
|
backend: normalized.spec.backend,
|
|
1141
1241
|
model: normalized.spec.model ?? null,
|
|
1242
|
+
...(native ? { delegation: native.delegation, nativeIdentity: native.nativeIdentity } : {}),
|
|
1142
1243
|
operation: normalized.spec.operation,
|
|
1143
1244
|
permissionMode: normalized.spec.permission_mode,
|
|
1144
1245
|
workspaceMode: normalized.spec.workspace_mode,
|
|
@@ -1149,6 +1250,9 @@ export function createDurableLocalTaskWorker({
|
|
|
1149
1250
|
outputsRoot: workspace.outputs_root,
|
|
1150
1251
|
outputsRootExpected: workspace.outputs_root_expected,
|
|
1151
1252
|
attachments,
|
|
1253
|
+
localDestination: requestedDestination,
|
|
1254
|
+
localPhotoSource: normalized.spec.local_photo_source,
|
|
1255
|
+
localPhotoContext: photoContext,
|
|
1152
1256
|
expectedOutputs: normalized.spec.expected_outputs,
|
|
1153
1257
|
scenarioPack: normalized.scenarioPack,
|
|
1154
1258
|
providerTokenBudget: normalized.spec.provider_token_budget,
|
|
@@ -1161,8 +1265,20 @@ export function createDurableLocalTaskWorker({
|
|
|
1161
1265
|
registry.create(task);
|
|
1162
1266
|
if (active) active.task = task;
|
|
1163
1267
|
|
|
1268
|
+
const checkLease = async () => {
|
|
1269
|
+
if (!leaseLost && now() >= knownLeaseExpiry) {
|
|
1270
|
+
await stopForLeaseLoss(workerError(
|
|
1271
|
+
"The local execution lease expired while waiting for SIMY Web.",
|
|
1272
|
+
"local_task_lease_expired",
|
|
1273
|
+
));
|
|
1274
|
+
}
|
|
1275
|
+
if (leaseLost) await leaseLoss;
|
|
1276
|
+
return !leaseLost;
|
|
1277
|
+
};
|
|
1164
1278
|
const heartbeat = async () => {
|
|
1165
|
-
if (!active || active.claim.turn_id !== normalized.turnId) return;
|
|
1279
|
+
if (!active || active.claim.turn_id !== normalized.turnId || leaseLost) return;
|
|
1280
|
+
const renewalStartedAt = now();
|
|
1281
|
+
if (!await checkLease()) return;
|
|
1166
1282
|
try {
|
|
1167
1283
|
const response = await api.heartbeat({
|
|
1168
1284
|
turnId: normalized.turnId,
|
|
@@ -1170,6 +1286,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1170
1286
|
leaseEpoch: normalized.leaseEpoch,
|
|
1171
1287
|
leaseSeconds,
|
|
1172
1288
|
});
|
|
1289
|
+
if (!await checkLease()) return;
|
|
1173
1290
|
if (response.ok !== true) {
|
|
1174
1291
|
await stopForLeaseLoss(
|
|
1175
1292
|
workerError(
|
|
@@ -1179,7 +1296,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1179
1296
|
);
|
|
1180
1297
|
return;
|
|
1181
1298
|
}
|
|
1182
|
-
knownLeaseExpiry =
|
|
1299
|
+
knownLeaseExpiry = renewalStartedAt + leaseSeconds * 1_000;
|
|
1183
1300
|
if (response.cancelRequested) {
|
|
1184
1301
|
cancelRequested = true;
|
|
1185
1302
|
await stopTask(task);
|
|
@@ -1201,7 +1318,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1201
1318
|
);
|
|
1202
1319
|
if (
|
|
1203
1320
|
Number.isFinite(knownLeaseExpiry) &&
|
|
1204
|
-
|
|
1321
|
+
now() + heartbeatIntervalMs >= knownLeaseExpiry
|
|
1205
1322
|
) {
|
|
1206
1323
|
await stopForLeaseLoss(error);
|
|
1207
1324
|
}
|
|
@@ -1213,17 +1330,32 @@ export function createDurableLocalTaskWorker({
|
|
|
1213
1330
|
heartbeatInFlight = null;
|
|
1214
1331
|
});
|
|
1215
1332
|
}
|
|
1216
|
-
return heartbeatInFlight;
|
|
1333
|
+
return Promise.race([heartbeatInFlight, leaseLoss]);
|
|
1217
1334
|
};
|
|
1218
1335
|
|
|
1219
1336
|
heartbeatTimer = setIntervalImpl(() => {
|
|
1220
|
-
|
|
1337
|
+
// Expiry must be checked even while a heartbeat request is still pending.
|
|
1338
|
+
void checkLease().then((valid) => { if (valid) void runHeartbeat(); });
|
|
1221
1339
|
}, heartbeatIntervalMs);
|
|
1222
1340
|
heartbeatTimer?.unref?.();
|
|
1223
1341
|
|
|
1224
1342
|
const runner = normalized.narrativeImageRequest ? runNarrativeImageTask : runLocalTask;
|
|
1225
1343
|
const completed = await runner(task, {
|
|
1344
|
+
...(native ? { delegationOptions: { onWorkspaceAuthorized: async roots => {
|
|
1345
|
+
await runHeartbeat();
|
|
1346
|
+
if (leaseLost || cancelRequested || closed || Date.now() >= knownLeaseExpiry)
|
|
1347
|
+
throw workerError('The Local Task lease no longer permits destination access.', 'local_task_cancelled_before_invocation');
|
|
1348
|
+
if (requestedDestination) destination = await resolveLocalTaskDestination(normalized.spec, {
|
|
1349
|
+
deviceId, resolveRepository, nativeWritableRoots: roots,
|
|
1350
|
+
});
|
|
1351
|
+
task.native_writable_roots = [...roots];
|
|
1352
|
+
} }, beforeSpawn: async () => {
|
|
1353
|
+
await runHeartbeat();
|
|
1354
|
+
if (leaseLost || cancelRequested || closed || Date.now() >= knownLeaseExpiry)
|
|
1355
|
+
throw workerError('The Local Task lease no longer permits dispatch.', 'local_task_cancelled_before_invocation');
|
|
1356
|
+
} } : {}),
|
|
1226
1357
|
beforeProviderInvocation: async () => {
|
|
1358
|
+
await checkLease();
|
|
1227
1359
|
if (leaseLost || cancelRequested || closed) {
|
|
1228
1360
|
throw workerError(
|
|
1229
1361
|
"The Local Task was stopped before the Provider started.",
|
|
@@ -1237,8 +1369,13 @@ export function createDurableLocalTaskWorker({
|
|
|
1237
1369
|
leaseEpoch: normalized.leaseEpoch,
|
|
1238
1370
|
});
|
|
1239
1371
|
boundary = "started";
|
|
1372
|
+
// The server ACK can arrive after cancellation or lease expiry.
|
|
1373
|
+
if (!await checkLease() || cancelRequested || closed) {
|
|
1374
|
+
throw workerError("The execution authority expired before Provider dispatch.",
|
|
1375
|
+
"local_task_cancelled_before_invocation");
|
|
1376
|
+
}
|
|
1240
1377
|
} catch (error) {
|
|
1241
|
-
boundary = error?.ambiguous ? "unknown" : "not_started";
|
|
1378
|
+
boundary = boundary === "started" || error?.ambiguous ? "unknown" : "not_started";
|
|
1242
1379
|
throw workerError(
|
|
1243
1380
|
error?.ambiguous
|
|
1244
1381
|
? "SIMY could not confirm whether the Provider boundary was recorded. The task was not started again."
|
|
@@ -1252,16 +1389,17 @@ export function createDurableLocalTaskWorker({
|
|
|
1252
1389
|
},
|
|
1253
1390
|
});
|
|
1254
1391
|
|
|
1392
|
+
if (!await checkLease()) return;
|
|
1393
|
+
await runHeartbeat();
|
|
1394
|
+
if (leaseLost) return;
|
|
1255
1395
|
if (heartbeatTimer) {
|
|
1256
1396
|
clearIntervalImpl(heartbeatTimer);
|
|
1257
1397
|
heartbeatTimer = null;
|
|
1258
1398
|
}
|
|
1259
|
-
await runHeartbeat();
|
|
1260
|
-
if (leaseLost) return;
|
|
1261
1399
|
// Report the Provider operation's observed outcome. A cancellation that
|
|
1262
1400
|
// arrives after Provider completion still cancels the parent Turn on the
|
|
1263
1401
|
// server, but must not rewrite a known successful operation as cancelled.
|
|
1264
|
-
const status = completed.native_image_unknown === true
|
|
1402
|
+
const status = completed.native_image_unknown === true || completed.delegation_state === "result_unknown"
|
|
1265
1403
|
? "unknown"
|
|
1266
1404
|
: completed.status === "stopped"
|
|
1267
1405
|
? "cancelled"
|
|
@@ -1278,15 +1416,41 @@ export function createDurableLocalTaskWorker({
|
|
|
1278
1416
|
let reportFailureCode = failureCode;
|
|
1279
1417
|
let reportSummary = durableResultSummary(completed, status, failureCode);
|
|
1280
1418
|
let artifactRefs = [];
|
|
1419
|
+
let deliveryStage = "artifacts";
|
|
1281
1420
|
try {
|
|
1282
1421
|
if (reportStatus === "succeeded" && normalized.narrativeImageRequest &&
|
|
1283
1422
|
!completed.native_image_receipt) {
|
|
1284
1423
|
throw workerError("Native image receipt is required before upload.", "narrative_image_receipt_missing");
|
|
1285
1424
|
}
|
|
1286
1425
|
artifactRefs = buildLocalTaskArtifactRefs(completed.artifacts, completed.id);
|
|
1426
|
+
if (reportStatus === "succeeded" && requestedDestination) {
|
|
1427
|
+
deliveryStage = "destination";
|
|
1428
|
+
const currentDestination = await resolveLocalTaskDestination(normalized.spec, { deviceId, resolveRepository, nativeWritableRoots: completed.native_writable_roots });
|
|
1429
|
+
if (destination && destination.root !== currentDestination.root) {
|
|
1430
|
+
throw workerError("The save destination does not match this device and its authorized workspace.", "local_task_destination_unconfirmed");
|
|
1431
|
+
}
|
|
1432
|
+
destination = currentDestination;
|
|
1433
|
+
completed.local_destination_receipts = [];
|
|
1434
|
+
await saveLocalTaskDestination({
|
|
1435
|
+
task: completed,
|
|
1436
|
+
destination,
|
|
1437
|
+
onReceipt: async (receipt) => {
|
|
1438
|
+
completed.local_destination_receipts.push(receipt);
|
|
1439
|
+
await persist(completed);
|
|
1440
|
+
},
|
|
1441
|
+
assertActive: () => {
|
|
1442
|
+
if (leaseLost || cancelRequested || closed || Date.now() >= knownLeaseExpiry) {
|
|
1443
|
+
throw workerError("The Local Task stopped before local file delivery.", "local_task_destination_unconfirmed");
|
|
1444
|
+
}
|
|
1445
|
+
},
|
|
1446
|
+
});
|
|
1447
|
+
await persist(completed);
|
|
1448
|
+
reportSummary += `\nSaved and verified on the selected device: ${completed.local_destination_receipts.map((receipt) => receipt.local_path).join(", ")}`;
|
|
1449
|
+
}
|
|
1450
|
+
deliveryStage = "artifacts";
|
|
1287
1451
|
if (
|
|
1288
|
-
reportStatus === "succeeded" &&
|
|
1289
|
-
(normalized.pipelineContext || normalized.narrativeImageRequest) &&
|
|
1452
|
+
(reportStatus === "succeeded" || native) &&
|
|
1453
|
+
(normalized.pipelineContext || normalized.narrativeImageRequest || native) &&
|
|
1290
1454
|
artifactRefs.length > 0
|
|
1291
1455
|
) {
|
|
1292
1456
|
artifactRefs = await materializeArtifacts({
|
|
@@ -1294,6 +1458,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1294
1458
|
turnId: normalized.turnId,
|
|
1295
1459
|
task: completed,
|
|
1296
1460
|
artifactRefs,
|
|
1461
|
+
...(native ? { preserveAmbiguity: true, workerId, leaseEpoch: normalized.leaseEpoch } : {}),
|
|
1297
1462
|
...(normalized.narrativeImageRequest ? { nativeImageReceipt: completed.native_image_receipt, workerId, leaseEpoch: normalized.leaseEpoch } : {}),
|
|
1298
1463
|
});
|
|
1299
1464
|
}
|
|
@@ -1306,19 +1471,26 @@ export function createDurableLocalTaskWorker({
|
|
|
1306
1471
|
if (status !== "cancelled") {
|
|
1307
1472
|
// A gateway 5xx can hide a committed image. Never present that outcome
|
|
1308
1473
|
// as a clean failure that invites a second generation.
|
|
1309
|
-
reportStatus = status === "unknown" || (normalized.narrativeImageRequest &&
|
|
1474
|
+
reportStatus = status === "unknown" || ((normalized.narrativeImageRequest || native) &&
|
|
1310
1475
|
(error?.ambiguous === true || error?.status >= 500)) ? "unknown" : "failed";
|
|
1311
1476
|
reportFailureCode = normalized.narrativeImageRequest && error?.code === "narrative_image_receipt_missing"
|
|
1312
1477
|
? "narrative_image_receipt_missing"
|
|
1478
|
+
: deliveryStage === "destination" || error?.code === "local_task_destination_unconfirmed"
|
|
1479
|
+
? "local_task_destination_unconfirmed"
|
|
1313
1480
|
: contractFailure
|
|
1314
1481
|
? "artifact_verification_failed"
|
|
1315
1482
|
: "artifact_materialization_failed";
|
|
1316
1483
|
reportSummary =
|
|
1317
|
-
normalized.narrativeImageRequest
|
|
1484
|
+
native ? recoverySummary(reportStatus, reportFailureCode) : normalized.narrativeImageRequest
|
|
1318
1485
|
? "SIMY could not confirm the generated image delivery. Keep the local output for verification; do not regenerate it."
|
|
1486
|
+
: deliveryStage === "destination"
|
|
1487
|
+
? "SIMY could not confirm delivery to the selected local folder. Existing files and local outputs were preserved."
|
|
1319
1488
|
: contractFailure
|
|
1320
1489
|
? "SIMY could not safely verify the Local Task output files."
|
|
1321
1490
|
: "SIMY created the output files locally but could not safely attach them to this Pipeline run. Keep SIMY CLI running, check the connection, and start a new Pipeline run.";
|
|
1491
|
+
if (completed.local_destination_receipts?.length) {
|
|
1492
|
+
reportSummary += `\nSaved and verified on the selected device: ${completed.local_destination_receipts.map((receipt) => receipt.local_path).join(", ")}`;
|
|
1493
|
+
}
|
|
1322
1494
|
completed.status = reportStatus === "failed" ? "failed" : completed.status;
|
|
1323
1495
|
completed.reason_code = reportFailureCode;
|
|
1324
1496
|
completed.error = error.message;
|
|
@@ -1342,18 +1514,40 @@ export function createDurableLocalTaskWorker({
|
|
|
1342
1514
|
error,
|
|
1343
1515
|
));
|
|
1344
1516
|
}
|
|
1517
|
+
const terminalPayload = {
|
|
1518
|
+
turnId: normalized.turnId,
|
|
1519
|
+
workerId,
|
|
1520
|
+
leaseEpoch: normalized.leaseEpoch,
|
|
1521
|
+
status: reportStatus,
|
|
1522
|
+
resultSummary: reportSummary,
|
|
1523
|
+
artifactRefs,
|
|
1524
|
+
failureCode: reportFailureCode,
|
|
1525
|
+
...(native ? { delegationResult: nativeDelegationResult(completed) } : {}),
|
|
1526
|
+
};
|
|
1527
|
+
completed.durable_pending_status = { device_id: deviceId, payload: terminalPayload };
|
|
1528
|
+
await persist(completed);
|
|
1345
1529
|
let statusConfirmed;
|
|
1346
1530
|
try {
|
|
1347
|
-
statusConfirmed = await updateStatusWithResolution(api,
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
leaseEpoch: normalized.leaseEpoch,
|
|
1351
|
-
status: reportStatus,
|
|
1352
|
-
resultSummary: reportSummary,
|
|
1353
|
-
artifactRefs,
|
|
1354
|
-
failureCode: reportFailureCode,
|
|
1355
|
-
});
|
|
1531
|
+
statusConfirmed = await updateStatusWithResolution(api, terminalPayload);
|
|
1532
|
+
completed.durable_pending_status = null;
|
|
1533
|
+
await persist(completed);
|
|
1356
1534
|
} catch (error) {
|
|
1535
|
+
if (isProvenFinalStatusRejection(error)) {
|
|
1536
|
+
// The final receipt was definitively rejected. Retain the completed
|
|
1537
|
+
// task as unconfirmed evidence, but do not let an unresendable
|
|
1538
|
+
// receipt block every later claim or invite a Provider replay.
|
|
1539
|
+
completed.durable_pending_status = null;
|
|
1540
|
+
completed.durable_status_unconfirmed = true;
|
|
1541
|
+
await persist(completed);
|
|
1542
|
+
onError(
|
|
1543
|
+
recoveryError(
|
|
1544
|
+
"SIMY completed the local Provider call but the final server state was rejected. It will not rerun the Provider.",
|
|
1545
|
+
error,
|
|
1546
|
+
"local_task_status_unconfirmed",
|
|
1547
|
+
),
|
|
1548
|
+
);
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1357
1551
|
onError(
|
|
1358
1552
|
recoveryError(
|
|
1359
1553
|
"SIMY completed the local Provider call but could not save its final server state. It will not rerun the Provider.",
|
|
@@ -1364,6 +1558,8 @@ export function createDurableLocalTaskWorker({
|
|
|
1364
1558
|
return;
|
|
1365
1559
|
}
|
|
1366
1560
|
if (!statusConfirmed) {
|
|
1561
|
+
completed.durable_status_unconfirmed = true;
|
|
1562
|
+
await persist(completed);
|
|
1367
1563
|
onError(
|
|
1368
1564
|
recoveryError(
|
|
1369
1565
|
"SIMY stopped this Local Task but could not confirm its final server state. It will not rerun the Provider.",
|
|
@@ -1383,6 +1579,10 @@ export function createDurableLocalTaskWorker({
|
|
|
1383
1579
|
);
|
|
1384
1580
|
const claimStatus =
|
|
1385
1581
|
boundary === "not_started" ? "failed" : "unknown";
|
|
1582
|
+
let rejectedNative = null;
|
|
1583
|
+
if (boundary === 'not_started' && claim.execution_spec?.delegation_target) {
|
|
1584
|
+
try { rejectedNative = nativeDelegationFailureResult(claim, deviceId); } catch { /* Unbound claims cannot produce an owner receipt. */ }
|
|
1585
|
+
}
|
|
1386
1586
|
const statusConfirmed = await updateStatusWithResolution(api, {
|
|
1387
1587
|
turnId: claim.turn_id,
|
|
1388
1588
|
workerId,
|
|
@@ -1396,6 +1596,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1396
1596
|
),
|
|
1397
1597
|
artifactRefs: [],
|
|
1398
1598
|
failureCode: safeMachineCode(error?.code, "durable_local_task_failed"),
|
|
1599
|
+
...(rejectedNative ? { delegationResult: rejectedNative } : {}),
|
|
1399
1600
|
}).catch((statusError) => {
|
|
1400
1601
|
onError(statusError);
|
|
1401
1602
|
return false;
|
|
@@ -1459,9 +1660,59 @@ export function createDurableLocalTaskWorker({
|
|
|
1459
1660
|
pollPromise = (async () => {
|
|
1460
1661
|
const { api, attachmentStore, deviceId } = context;
|
|
1461
1662
|
try {
|
|
1663
|
+
// The provider operation and saved files are already known. After an
|
|
1664
|
+
// offline result write, retry only that exact lease-bound receipt on
|
|
1665
|
+
// the same paired device; never claim or rerun the provider here.
|
|
1666
|
+
const pendingTask = registry.list?.().find(task => task.durable_pending_status);
|
|
1667
|
+
if (pendingTask) {
|
|
1668
|
+
const pending = pendingTask.durable_pending_status;
|
|
1669
|
+
if (!deviceId || !pending.device_id) {
|
|
1670
|
+
// Pairing state can be temporarily unavailable during a refresh.
|
|
1671
|
+
// Keep the exact receipt pending until the original device can
|
|
1672
|
+
// be identified; never reinterpret a missing device ID as a
|
|
1673
|
+
// device change.
|
|
1674
|
+
onError(workerError("The saved result is waiting for its original paired device.", "local_task_result_device_mismatch"));
|
|
1675
|
+
releaseWork();
|
|
1676
|
+
return false;
|
|
1677
|
+
}
|
|
1678
|
+
if (pending.device_id !== deviceId) {
|
|
1679
|
+
// A receipt belongs to its original pairing. Keeping it pending
|
|
1680
|
+
// on another device would permanently prevent that device from
|
|
1681
|
+
// claiming unrelated work, while resending would violate the
|
|
1682
|
+
// device-bound receipt contract.
|
|
1683
|
+
pendingTask.durable_pending_status = null;
|
|
1684
|
+
pendingTask.durable_status_unconfirmed = true;
|
|
1685
|
+
await persist(pendingTask);
|
|
1686
|
+
onError(workerError("The saved result belongs to a different paired device and will not be resent or replayed.", "local_task_result_device_mismatch"));
|
|
1687
|
+
} else {
|
|
1688
|
+
try {
|
|
1689
|
+
const confirmed = await updateStatusWithResolution(api, pending.payload);
|
|
1690
|
+
pendingTask.durable_pending_status = null;
|
|
1691
|
+
if (!confirmed) pendingTask.durable_status_unconfirmed = true;
|
|
1692
|
+
await persist(pendingTask);
|
|
1693
|
+
if (!confirmed) onError(workerError("The original result lease was fenced; provider replay remains disabled.", "local_task_status_unconfirmed"));
|
|
1694
|
+
releaseWork();
|
|
1695
|
+
return confirmed;
|
|
1696
|
+
} catch (error) {
|
|
1697
|
+
if (!isProvenFinalStatusRejection(error)) throw error;
|
|
1698
|
+
// A proven request/authorization/not-found/validation
|
|
1699
|
+
// rejection cannot become valid by retrying this receipt.
|
|
1700
|
+
// Preserve local evidence and continue to an unrelated claim.
|
|
1701
|
+
pendingTask.durable_pending_status = null;
|
|
1702
|
+
pendingTask.durable_status_unconfirmed = true;
|
|
1703
|
+
await persist(pendingTask);
|
|
1704
|
+
onError(recoveryError(
|
|
1705
|
+
"SIMY rejected the saved final result. It will not rerun the Provider.",
|
|
1706
|
+
error,
|
|
1707
|
+
"local_task_status_unconfirmed",
|
|
1708
|
+
));
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1462
1712
|
const requestedTurnId = pendingStepTurnId;
|
|
1463
1713
|
if (
|
|
1464
1714
|
!requestedTurnId &&
|
|
1715
|
+
enableBoundedSubtasks &&
|
|
1465
1716
|
typeof api.claimSubtaskParent === "function"
|
|
1466
1717
|
) {
|
|
1467
1718
|
const parentEnvelope = await api.claimSubtaskParent({
|
|
@@ -1488,6 +1739,7 @@ export function createDurableLocalTaskWorker({
|
|
|
1488
1739
|
prepareWorkspace,
|
|
1489
1740
|
leaseSeconds,
|
|
1490
1741
|
heartbeatIntervalMs,
|
|
1742
|
+
now,
|
|
1491
1743
|
setIntervalImpl,
|
|
1492
1744
|
clearIntervalImpl,
|
|
1493
1745
|
onError,
|
|
@@ -1535,6 +1787,9 @@ export function createDurableLocalTaskWorker({
|
|
|
1535
1787
|
releaseWork();
|
|
1536
1788
|
return false;
|
|
1537
1789
|
}
|
|
1790
|
+
if ((isLocalTaskPlanOrchestration(claim) || isDurableLocalTaskStepClaim(claim)) &&
|
|
1791
|
+
(claim.execution_spec?.delegation || claim.execution_spec?.delegation_target || claim.execution_spec?.native_identity))
|
|
1792
|
+
throw workerError('Native delegation requires the admitted single provider turn.', 'native_delegation_execution_mode_unsupported');
|
|
1538
1793
|
if (isLocalTaskPlanOrchestration(claim)) {
|
|
1539
1794
|
const orchestration = claim;
|
|
1540
1795
|
const controlledPlan = buildControlledLocalTaskPlan(orchestration);
|
|
@@ -1572,6 +1827,20 @@ export function createDurableLocalTaskWorker({
|
|
|
1572
1827
|
"local_task_plan_claim_mismatch",
|
|
1573
1828
|
);
|
|
1574
1829
|
}
|
|
1830
|
+
if (registry.list?.().some(task =>
|
|
1831
|
+
task.durable_status_unconfirmed === true && task.durable_turn_id === claim.turn_id
|
|
1832
|
+
)) {
|
|
1833
|
+
// This device has already completed this exact Provider turn but
|
|
1834
|
+
// cannot prove the server accepted its terminal receipt. A lease
|
|
1835
|
+
// replay must never cause a second Provider invocation; unrelated
|
|
1836
|
+
// claims remain eligible on the following poll.
|
|
1837
|
+
releaseWork();
|
|
1838
|
+
onError(workerError(
|
|
1839
|
+
"SIMY will not rerun a Local Task whose prior Provider result is unconfirmed.",
|
|
1840
|
+
"local_task_status_unconfirmed",
|
|
1841
|
+
));
|
|
1842
|
+
return false;
|
|
1843
|
+
}
|
|
1575
1844
|
if (isDurableLocalTaskStepClaim(claim)) {
|
|
1576
1845
|
const normalized = normalizeDurableLocalTaskStepClaim(claim, {
|
|
1577
1846
|
leaseSeconds,
|
|
@@ -1694,15 +1963,23 @@ async function updateStatusWithResolution(api, input) {
|
|
|
1694
1963
|
if (error instanceof DurableLocalTaskApiError && error.status === 409) {
|
|
1695
1964
|
return false;
|
|
1696
1965
|
}
|
|
1697
|
-
const retryable =
|
|
1698
|
-
error?.ambiguous === true ||
|
|
1699
|
-
(Number.isInteger(error?.status) && error.status >= 500);
|
|
1966
|
+
const retryable = isRetryableStatusResolutionError(error);
|
|
1700
1967
|
if (!retryable || attempt > 0) throw error;
|
|
1701
1968
|
}
|
|
1702
1969
|
}
|
|
1703
1970
|
throw lastError;
|
|
1704
1971
|
}
|
|
1705
1972
|
|
|
1973
|
+
function isRetryableStatusResolutionError(error) {
|
|
1974
|
+
return error?.ambiguous === true ||
|
|
1975
|
+
(Number.isInteger(error?.status) && error.status >= 500);
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
function isProvenFinalStatusRejection(error) {
|
|
1979
|
+
return error instanceof DurableLocalTaskApiError &&
|
|
1980
|
+
[400, 403, 404, 422].includes(error.status);
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1706
1983
|
function normalizeClaim(claim, { leaseSeconds, nowMs = Date.now() } = {}) {
|
|
1707
1984
|
const turnId = String(claim?.turn_id || "");
|
|
1708
1985
|
const leaseEpoch = Number(claim?.lease_epoch);
|
|
@@ -1733,7 +2010,7 @@ function normalizeClaim(claim, { leaseSeconds, nowMs = Date.now() } = {}) {
|
|
|
1733
2010
|
if (!instruction) {
|
|
1734
2011
|
throw workerError("The Local Task has no instruction.", "local_task_instruction_required");
|
|
1735
2012
|
}
|
|
1736
|
-
|
|
2013
|
+
let attachmentRefs = payload.attachment_refs ?? [];
|
|
1737
2014
|
if (!Array.isArray(attachmentRefs)) {
|
|
1738
2015
|
throw workerError(
|
|
1739
2016
|
"SIMY Web returned invalid attached-file references.",
|
|
@@ -1741,6 +2018,16 @@ function normalizeClaim(claim, { leaseSeconds, nowMs = Date.now() } = {}) {
|
|
|
1741
2018
|
);
|
|
1742
2019
|
}
|
|
1743
2020
|
validateExecutionSpec(spec);
|
|
2021
|
+
const uploadedRefs = attachmentRefs.filter(ref => ref?.schema_version === 'simy_uploaded_attachment_ref.v1');
|
|
2022
|
+
if (uploadedRefs.length) {
|
|
2023
|
+
if (!spec.delegation_target || spec.uploaded_attachment_capability_version !== 'local_task_uploaded_sources.v2')
|
|
2024
|
+
throw workerError('Unsupported uploaded source capability.', 'unsupported_local_task_attachment_capability');
|
|
2025
|
+
uploadedRefs.forEach(ref => normalizeParentUploadedRef(ref));
|
|
2026
|
+
if (new Set(uploadedRefs.map(ref => ref.source_id)).size !== uploadedRefs.length)
|
|
2027
|
+
throw workerError('Duplicate uploaded source.', 'invalid_local_task_claim');
|
|
2028
|
+
validateLocalTaskFileCollection(attachmentRefs, { direction: 'input' });
|
|
2029
|
+
attachmentRefs = attachmentRefs.filter(ref => ref?.schema_version !== 'simy_uploaded_attachment_ref.v1');
|
|
2030
|
+
}
|
|
1744
2031
|
if (
|
|
1745
2032
|
attachmentRefs.length > 0 &&
|
|
1746
2033
|
spec.attachment_capability_version !== LOCAL_TASK_FILE_CAPABILITY_VERSION
|
|
@@ -1762,6 +2049,7 @@ function normalizeClaim(claim, { leaseSeconds, nowMs = Date.now() } = {}) {
|
|
|
1762
2049
|
leaseExpiresAtMs,
|
|
1763
2050
|
instruction,
|
|
1764
2051
|
attachmentRefs,
|
|
2052
|
+
uploadedRefs,
|
|
1765
2053
|
spec,
|
|
1766
2054
|
pipelineContext,
|
|
1767
2055
|
scenarioPack,
|
|
@@ -1879,6 +2167,7 @@ async function resolveWorkspace(
|
|
|
1879
2167
|
repositoryPath: repository.local_path,
|
|
1880
2168
|
expectedRepository: normalized.spec.repository,
|
|
1881
2169
|
localTaskId,
|
|
2170
|
+
prepareOutputs: normalized.spec.permission_mode === "workspace_write",
|
|
1882
2171
|
});
|
|
1883
2172
|
return {
|
|
1884
2173
|
...prepared,
|
|
@@ -2174,10 +2463,11 @@ async function persistDurableStepAttachmentManifest({
|
|
|
2174
2463
|
}
|
|
2175
2464
|
}
|
|
2176
2465
|
|
|
2177
|
-
async function prepareRepositoryWorkspace({
|
|
2466
|
+
export async function prepareRepositoryWorkspace({
|
|
2178
2467
|
repositoryPath,
|
|
2179
2468
|
expectedRepository,
|
|
2180
2469
|
localTaskId,
|
|
2470
|
+
prepareOutputs = true,
|
|
2181
2471
|
}) {
|
|
2182
2472
|
const candidate = path.resolve(String(repositoryPath || ""));
|
|
2183
2473
|
const candidateDetails = await lstat(candidate).catch(() => null);
|
|
@@ -2216,6 +2506,8 @@ async function prepareRepositoryWorkspace({
|
|
|
2216
2506
|
);
|
|
2217
2507
|
}
|
|
2218
2508
|
|
|
2509
|
+
if (!prepareOutputs) return { workspace_path: repositoryRoot };
|
|
2510
|
+
|
|
2219
2511
|
const trackedOutputs = await gitOutput(repositoryRoot, [
|
|
2220
2512
|
"ls-files",
|
|
2221
2513
|
"--",
|
|
@@ -2364,6 +2656,7 @@ export async function materializePipelineArtifacts({
|
|
|
2364
2656
|
task,
|
|
2365
2657
|
artifactRefs,
|
|
2366
2658
|
nativeImageReceipt = null,
|
|
2659
|
+
preserveAmbiguity = false,
|
|
2367
2660
|
workerId = null,
|
|
2368
2661
|
leaseEpoch = null,
|
|
2369
2662
|
}) {
|
|
@@ -2405,7 +2698,8 @@ export async function materializePipelineArtifacts({
|
|
|
2405
2698
|
mimeType: ref.mime_type,
|
|
2406
2699
|
sizeBytes: ref.size_bytes,
|
|
2407
2700
|
sha256: ref.sha256.toLowerCase(),
|
|
2408
|
-
...(
|
|
2701
|
+
...(workerId !== null ? { workerId, leaseEpoch } : {}),
|
|
2702
|
+
...(nativeImageReceipt ? { nativeImageReceipt } : {}),
|
|
2409
2703
|
}));
|
|
2410
2704
|
validatePreparedArtifact(prepared);
|
|
2411
2705
|
await retryAmbiguousArtifactStep(() =>
|
|
@@ -2422,8 +2716,9 @@ export async function materializePipelineArtifacts({
|
|
|
2422
2716
|
localArtifactId: ref.id,
|
|
2423
2717
|
sha256: ref.sha256.toLowerCase(),
|
|
2424
2718
|
sizeBytes: ref.size_bytes,
|
|
2425
|
-
...(
|
|
2426
|
-
|
|
2719
|
+
...(workerId !== null ? { workerId, leaseEpoch } : {}),
|
|
2720
|
+
...(nativeImageReceipt ? { nativeImageReceipt } : {}),
|
|
2721
|
+
}), { preserveAmbiguity: Boolean(nativeImageReceipt) || preserveAmbiguity });
|
|
2427
2722
|
materialized.push(validateCommittedArtifact(committed, ref));
|
|
2428
2723
|
}
|
|
2429
2724
|
return materialized;
|
|
@@ -2684,3 +2979,22 @@ function plainRecovery(error) {
|
|
|
2684
2979
|
function errorMessage(error) {
|
|
2685
2980
|
return error instanceof Error ? error.message : String(error || "");
|
|
2686
2981
|
}
|
|
2982
|
+
|
|
2983
|
+
async function materializeNativeUploadedInputs({ refs, inputsRoot, download }) {
|
|
2984
|
+
const root = await realpath(inputsRoot), inputs = [];
|
|
2985
|
+
for (const ref of refs) {
|
|
2986
|
+
const bytes = await download(ref);
|
|
2987
|
+
validateLocalTaskFile({ ...ref, size_bytes: Number(ref.size_bytes), bytes }, { direction: 'input', requireBytes: true });
|
|
2988
|
+
if (!Buffer.isBuffer(bytes) || bytes.length !== Number(ref.size_bytes) || createHash('sha256').update(bytes).digest('hex') !== ref.sha256)
|
|
2989
|
+
throw workerError('Uploaded source integrity failed.', 'local_task_source_integrity_failed');
|
|
2990
|
+
const target = path.join(root, `${createHash('sha256').update(ref.source_id).digest('hex').slice(0, 16)}-${ref.name}`);
|
|
2991
|
+
if (path.dirname(target) !== root) throw workerError('Unsafe uploaded source path.', 'local_task_source_path_unsafe');
|
|
2992
|
+
await writeFile(target, bytes, { flag: 'wx', mode: 0o400 });
|
|
2993
|
+
const details = await lstat(target), canonical = await realpath(target);
|
|
2994
|
+
if (!details.isFile() || details.isSymbolicLink() || path.dirname(canonical) !== root ||
|
|
2995
|
+
createHash('sha256').update(await readFile(canonical)).digest('hex') !== ref.sha256)
|
|
2996
|
+
throw workerError('Uploaded source changed.', 'local_task_source_integrity_failed');
|
|
2997
|
+
inputs.push({ ...ref, id: ref.source_id, local_path: canonical, integrity_status: 'verified' });
|
|
2998
|
+
}
|
|
2999
|
+
return inputs;
|
|
3000
|
+
}
|