@phnx-labs/agents-cli 1.22.31 → 1.22.32
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 +66 -0
- package/README.md +8 -2
- package/dist/bin/agents +0 -0
- package/dist/commands/daemon.js +52 -12
- package/dist/commands/doctor.d.ts +19 -0
- package/dist/commands/doctor.js +119 -17
- package/dist/commands/routines.js +164 -36
- package/dist/commands/sessions.d.ts +1 -1
- package/dist/commands/sessions.js +44 -10
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.js +148 -0
- package/dist/index.js +3 -1
- package/dist/lib/catchup.js +4 -1
- package/dist/lib/daemon.d.ts +17 -0
- package/dist/lib/daemon.js +69 -3
- package/dist/lib/devices/doctor-findings.d.ts +7 -2
- package/dist/lib/devices/doctor-findings.js +53 -2
- package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
- package/dist/lib/devices/doctor-overview-cache.js +15 -0
- package/dist/lib/devices/fleet-divergence.d.ts +11 -0
- package/dist/lib/devices/fleet-divergence.js +6 -0
- package/dist/lib/devices/fleet-inventory.js +16 -2
- package/dist/lib/drift.d.ts +6 -1
- package/dist/lib/drift.js +9 -0
- package/dist/lib/hooks/cache.js +20 -1
- package/dist/lib/hooks.d.ts +91 -1
- package/dist/lib/hooks.js +289 -3
- package/dist/lib/hosts/passthrough.js +3 -0
- package/dist/lib/installations/index.d.ts +14 -0
- package/dist/lib/installations/index.js +14 -0
- package/dist/lib/installations/resolve.d.ts +43 -0
- package/dist/lib/installations/resolve.js +93 -0
- package/dist/lib/installations/store.d.ts +56 -0
- package/dist/lib/installations/store.js +196 -0
- package/dist/lib/installations/strategies.d.ts +73 -0
- package/dist/lib/installations/strategies.js +293 -0
- package/dist/lib/installations/types.d.ts +78 -0
- package/dist/lib/installations/types.js +8 -0
- package/dist/lib/installations/update.d.ts +40 -0
- package/dist/lib/installations/update.js +131 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +27 -0
- package/dist/lib/migrate.js +112 -2
- package/dist/lib/routine-context.d.ts +144 -0
- package/dist/lib/routine-context.js +268 -0
- package/dist/lib/routine-readiness.d.ts +47 -0
- package/dist/lib/routine-readiness.js +239 -0
- package/dist/lib/routines.d.ts +97 -1
- package/dist/lib/routines.js +107 -1
- package/dist/lib/runner.d.ts +18 -4
- package/dist/lib/runner.js +291 -98
- package/dist/lib/scheduler.d.ts +7 -1
- package/dist/lib/scheduler.js +5 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/self-heal/checks/hook-runtime.d.ts +2 -0
- package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
- package/dist/lib/self-heal/registry.js +5 -2
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/state.js +4 -1
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/versions.d.ts +24 -0
- package/dist/lib/versions.js +49 -16
- package/package.json +2 -2
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type UpdateStrategy } from './strategies.js';
|
|
2
|
+
import type { Installation, UpdateOutcome } from './types.js';
|
|
3
|
+
export interface UpdateInstallationOptions {
|
|
4
|
+
/** `latest` (default), `oldest`, or a concrete release. */
|
|
5
|
+
to?: string;
|
|
6
|
+
onProgress?: (message: string) => void;
|
|
7
|
+
/**
|
|
8
|
+
* Replace the registry-selected strategy. The seam exists so the transaction
|
|
9
|
+
* below can be exercised against a real filesystem without a vendor fetch, and
|
|
10
|
+
* so a track that installs a harness differently (per-installation Cursor
|
|
11
|
+
* isolation) can reuse this orchestration instead of re-implementing it.
|
|
12
|
+
* Omitted in every normal call — `selectUpdateStrategy` is the default.
|
|
13
|
+
*/
|
|
14
|
+
strategy?: UpdateStrategy;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Move one frozen installation to a new vendor release, preserving its identity.
|
|
18
|
+
*
|
|
19
|
+
* The transaction is stage → verify → commit → record, with rollback on any
|
|
20
|
+
* failure after the swap:
|
|
21
|
+
*
|
|
22
|
+
* 1. **stage** — fetch the target release somewhere that is not yet live.
|
|
23
|
+
* 2. **verify** — launch the STAGED binary. This is the gate that makes the
|
|
24
|
+
* update safe: a release that cannot start is discarded while
|
|
25
|
+
* the working one is still in place, so the failure mode is
|
|
26
|
+
* "nothing changed", not "the agent no longer runs".
|
|
27
|
+
* 3. **commit** — swap it in, keeping the displaced release until step 4.
|
|
28
|
+
* 4. **record** — re-verify in place, then write the new release into the
|
|
29
|
+
* installation record and drop the rollback material.
|
|
30
|
+
*
|
|
31
|
+
* The installation's `id` and `label` are never touched, so the global default,
|
|
32
|
+
* an isolated default, a project pin, a routine's `version:`, and a profile's
|
|
33
|
+
* `host.version` all keep resolving to this installation across the update —
|
|
34
|
+
* that reference preservation is the whole point of freezing the label.
|
|
35
|
+
*
|
|
36
|
+
* Strategies whose vendor artifact is global (a self-updating binary) report
|
|
37
|
+
* `transactional: false`; for those, step 3 is a no-op and a failed verify is
|
|
38
|
+
* surfaced as a failed update rather than pretended to be reversible.
|
|
39
|
+
*/
|
|
40
|
+
export declare function updateInstallation(installation: Installation, options?: UpdateInstallationOptions): Promise<UpdateOutcome>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import { AGENTS, isAgentHardDeprecated, hardDeprecationError } from '../agents.js';
|
|
3
|
+
import { emit } from '../events.js';
|
|
4
|
+
import { getBinaryPath, invalidateInstalledVersionsCache, invalidateLiveVersionCache, verifyBinaryLaunches, } from '../versions.js';
|
|
5
|
+
import { assertValidRelease, selectUpdateStrategy, } from './strategies.js';
|
|
6
|
+
import { listInstallations, recordRelease } from './store.js';
|
|
7
|
+
/**
|
|
8
|
+
* Move one frozen installation to a new vendor release, preserving its identity.
|
|
9
|
+
*
|
|
10
|
+
* The transaction is stage → verify → commit → record, with rollback on any
|
|
11
|
+
* failure after the swap:
|
|
12
|
+
*
|
|
13
|
+
* 1. **stage** — fetch the target release somewhere that is not yet live.
|
|
14
|
+
* 2. **verify** — launch the STAGED binary. This is the gate that makes the
|
|
15
|
+
* update safe: a release that cannot start is discarded while
|
|
16
|
+
* the working one is still in place, so the failure mode is
|
|
17
|
+
* "nothing changed", not "the agent no longer runs".
|
|
18
|
+
* 3. **commit** — swap it in, keeping the displaced release until step 4.
|
|
19
|
+
* 4. **record** — re-verify in place, then write the new release into the
|
|
20
|
+
* installation record and drop the rollback material.
|
|
21
|
+
*
|
|
22
|
+
* The installation's `id` and `label` are never touched, so the global default,
|
|
23
|
+
* an isolated default, a project pin, a routine's `version:`, and a profile's
|
|
24
|
+
* `host.version` all keep resolving to this installation across the update —
|
|
25
|
+
* that reference preservation is the whole point of freezing the label.
|
|
26
|
+
*
|
|
27
|
+
* Strategies whose vendor artifact is global (a self-updating binary) report
|
|
28
|
+
* `transactional: false`; for those, step 3 is a no-op and a failed verify is
|
|
29
|
+
* surfaced as a failed update rather than pretended to be reversible.
|
|
30
|
+
*/
|
|
31
|
+
export async function updateInstallation(installation, options = {}) {
|
|
32
|
+
const agent = installation.agent;
|
|
33
|
+
if (isAgentHardDeprecated(agent))
|
|
34
|
+
throw new Error(hardDeprecationError(agent));
|
|
35
|
+
const requested = options.to ?? 'latest';
|
|
36
|
+
assertValidRelease(requested);
|
|
37
|
+
const strategy = options.strategy ?? selectUpdateStrategy(agent);
|
|
38
|
+
const ctx = { agent, installation, requested, onProgress: options.onProgress };
|
|
39
|
+
const target = await strategy.resolveTarget(ctx);
|
|
40
|
+
if (target === installation.releaseVersion) {
|
|
41
|
+
options.onProgress?.(`${AGENTS[agent].name}@${installation.label} is already on release ${target}; nothing to update.`);
|
|
42
|
+
return {
|
|
43
|
+
installation,
|
|
44
|
+
strategy: strategy.id,
|
|
45
|
+
fromRelease: installation.releaseVersion,
|
|
46
|
+
toRelease: target,
|
|
47
|
+
unchanged: true,
|
|
48
|
+
alsoUpdated: [],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
let staged = null;
|
|
52
|
+
try {
|
|
53
|
+
staged = await strategy.stage(ctx, target);
|
|
54
|
+
const stagedHealth = await verifyBinaryLaunches(staged.binary, staged.home);
|
|
55
|
+
if (!stagedHealth.ok) {
|
|
56
|
+
throw new Error(`${AGENTS[agent].name} release ${staged.release} was fetched but its binary failed to launch`
|
|
57
|
+
+ `${stagedHealth.detail ? ` (${stagedHealth.detail})` : ''}. `
|
|
58
|
+
+ `${installation.label} is unchanged and still on ${installation.releaseVersion}.`);
|
|
59
|
+
}
|
|
60
|
+
// The installer may have reported a release the installation already has
|
|
61
|
+
// (a self-updating binary that was already current). Recording it would
|
|
62
|
+
// claim a change that did not happen and append a bogus history entry.
|
|
63
|
+
if (staged.release === installation.releaseVersion) {
|
|
64
|
+
options.onProgress?.(`${AGENTS[agent].name}@${installation.label} is already on release ${staged.release}; nothing to update.`);
|
|
65
|
+
// Deliberately NOT committed: there is no new release to make live, and
|
|
66
|
+
// for a strategy that swaps the version dir a commit here would displace
|
|
67
|
+
// a working tree, discard its rollback material, and skip the live probe —
|
|
68
|
+
// all while reporting that nothing changed. The `finally` clears staging.
|
|
69
|
+
return {
|
|
70
|
+
installation,
|
|
71
|
+
strategy: strategy.id,
|
|
72
|
+
fromRelease: installation.releaseVersion,
|
|
73
|
+
toRelease: staged.release,
|
|
74
|
+
unchanged: true,
|
|
75
|
+
alsoUpdated: [],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const handles = await strategy.commit(ctx, staged);
|
|
79
|
+
try {
|
|
80
|
+
// Probe what will actually execute — `getBinaryPath` is the same resolver
|
|
81
|
+
// the shims and `agents run` use — not the staging copy probed above.
|
|
82
|
+
const liveBinary = getBinaryPath(agent, installation.label);
|
|
83
|
+
const liveHealth = await verifyBinaryLaunches(liveBinary, staged.home);
|
|
84
|
+
if (!liveHealth.ok) {
|
|
85
|
+
throw new Error(`${AGENTS[agent].name} release ${staged.release} failed to launch after being installed`
|
|
86
|
+
+ `${liveHealth.detail ? ` (${liveHealth.detail})` : ''}.`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
// Undo unconditionally. `transactional` describes whether the VENDOR
|
|
91
|
+
// artifact can be put back, not whether this directory can — gating the
|
|
92
|
+
// undo on it left an installer-driven harness with the broken tree live
|
|
93
|
+
// AND the previous one orphaned in rollback material nothing deletes.
|
|
94
|
+
// A strategy with nothing to restore returns a no-op undo.
|
|
95
|
+
handles.undo();
|
|
96
|
+
throw new Error(strategy.transactional
|
|
97
|
+
? `${err.message} Rolled back to ${installation.releaseVersion}.`
|
|
98
|
+
: `${err.message} The version directory was restored, but ${AGENTS[agent].name}'s installer `
|
|
99
|
+
+ `had already replaced the binary it manages globally — repair it with: agents add ${agent}@latest`);
|
|
100
|
+
}
|
|
101
|
+
handles.finalize();
|
|
102
|
+
const updated = recordRelease(installation, staged.release);
|
|
103
|
+
// Several installations of a global-binary harness point at the same file,
|
|
104
|
+
// so the one we just replaced is live for all of them. Recording the release
|
|
105
|
+
// only on the target would leave the others claiming a release that is no
|
|
106
|
+
// longer on disk.
|
|
107
|
+
const alsoUpdated = strategy.sharedBinary
|
|
108
|
+
? listInstallations(agent)
|
|
109
|
+
.filter((other) => other.label !== installation.label && other.releaseVersion !== staged.release)
|
|
110
|
+
.map((other) => recordRelease(other, staged.release))
|
|
111
|
+
: [];
|
|
112
|
+
invalidateInstalledVersionsCache(agent);
|
|
113
|
+
invalidateLiveVersionCache(agent);
|
|
114
|
+
// A release really was installed, so this is the right event; `installation`
|
|
115
|
+
// says WHICH frozen install received it, since the label no longer equals
|
|
116
|
+
// the release.
|
|
117
|
+
emit('version.install', { agent, version: staged.release, installation: installation.label });
|
|
118
|
+
return {
|
|
119
|
+
installation: updated,
|
|
120
|
+
strategy: strategy.id,
|
|
121
|
+
fromRelease: installation.releaseVersion,
|
|
122
|
+
toRelease: staged.release,
|
|
123
|
+
unchanged: false,
|
|
124
|
+
alsoUpdated,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
if (staged?.stagingDir)
|
|
129
|
+
fs.rmSync(staged.stagingDir, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
Binary file
|
|
Binary file
|
package/dist/lib/migrate.d.ts
CHANGED
|
@@ -117,6 +117,33 @@ export declare function migrateExtrasExtrasToAgentsExtras(historyDir?: string):
|
|
|
117
117
|
* Params default to the real routines dir; injectable for tests.
|
|
118
118
|
*/
|
|
119
119
|
export declare function migrateRoutineDeviceToDevices(routinesDir?: string): void;
|
|
120
|
+
/**
|
|
121
|
+
* Fold the legacy host-placement `remoteCwd` field into the canonical portable
|
|
122
|
+
* `cwd` (RUSH-2290). Host dispatch used to read `remoteCwd` while a local run
|
|
123
|
+
* inferred its cwd from `repo` — two path semantics for one concept. The runner
|
|
124
|
+
* now resolves every placement from `cwd`, so this idempotently rewrites the
|
|
125
|
+
* field:
|
|
126
|
+
*
|
|
127
|
+
* - `remoteCwd` present, no `cwd` → rename to `cwd`.
|
|
128
|
+
* - both present and equal → drop the duplicate `remoteCwd`.
|
|
129
|
+
* - both present and DIFFERENT → conflict: leave BOTH fields untouched so
|
|
130
|
+
* the migration never silently chooses one; `validateJob`/`doctor` then flag the
|
|
131
|
+
* pair and the routine stays paused rather than running against a guessed path.
|
|
132
|
+
*
|
|
133
|
+
* Idempotent: a file with only `cwd` (already migrated) is skipped.
|
|
134
|
+
*/
|
|
135
|
+
export declare function migrateRoutineRemoteCwdToCwd(routinesDir?: string): void;
|
|
136
|
+
/**
|
|
137
|
+
* Pause every currently-active routine whose execution context no longer
|
|
138
|
+
* resolves ready (RUSH-2290). An agent/workflow routine with no project/cwd, a
|
|
139
|
+
* missing directory, or a non-portable path used to fire and fail every tick —
|
|
140
|
+
* the mass auth_failed / untrusted-home storm this ticket exists to stop. After
|
|
141
|
+
* the fold, such a routine is deactivated on THIS device (only), preventing it
|
|
142
|
+
* from being scheduled until `agents routines doctor --all --fix` (or a repair +
|
|
143
|
+
* `resume`) makes it ready. Never materializes a device manifest that does not
|
|
144
|
+
* yet exist, and never touches command routines (they run in the target home).
|
|
145
|
+
*/
|
|
146
|
+
export declare function pauseUnreadyEnabledRoutines(): void;
|
|
120
147
|
/**
|
|
121
148
|
* Fold the legacy watchdog enable sentinel into the watchdog routine.
|
|
122
149
|
*
|
package/dist/lib/migrate.js
CHANGED
|
@@ -13,8 +13,9 @@ import { atomicWriteFileSync } from './fs-atomic.js';
|
|
|
13
13
|
import { machineId } from './machine-id.js';
|
|
14
14
|
import { AGENTS, agentConfigDirName, findInPath } from './agents.js';
|
|
15
15
|
import { createLink } from './platform/index.js';
|
|
16
|
-
import { migrateLegacyRoutineActivation, setJobEnabled } from './routines.js';
|
|
17
|
-
import { addEnabledRoutinesOnUpgrade } from './routine-activation.js';
|
|
16
|
+
import { migrateLegacyRoutineActivation, setJobEnabled, listJobs, validateJob } from './routines.js';
|
|
17
|
+
import { addEnabledRoutinesOnUpgrade, enabledRoutineNames, replaceEnabledRoutines } from './routine-activation.js';
|
|
18
|
+
import { evaluateActivationReadiness } from './routine-readiness.js';
|
|
18
19
|
import { DAEMON_TICK_ROUTINE_NAMES } from './daemon-ticks.js';
|
|
19
20
|
const HOME = process.env.HOME ?? os.homedir();
|
|
20
21
|
const USER_DIR = path.join(HOME, '.agents');
|
|
@@ -1987,6 +1988,108 @@ export function migrateRoutineDeviceToDevices(routinesDir) {
|
|
|
1987
1988
|
console.error(`Migrated ${migrated} routine${migrated === 1 ? '' : 's'}: device → devices`);
|
|
1988
1989
|
}
|
|
1989
1990
|
}
|
|
1991
|
+
/**
|
|
1992
|
+
* Fold the legacy host-placement `remoteCwd` field into the canonical portable
|
|
1993
|
+
* `cwd` (RUSH-2290). Host dispatch used to read `remoteCwd` while a local run
|
|
1994
|
+
* inferred its cwd from `repo` — two path semantics for one concept. The runner
|
|
1995
|
+
* now resolves every placement from `cwd`, so this idempotently rewrites the
|
|
1996
|
+
* field:
|
|
1997
|
+
*
|
|
1998
|
+
* - `remoteCwd` present, no `cwd` → rename to `cwd`.
|
|
1999
|
+
* - both present and equal → drop the duplicate `remoteCwd`.
|
|
2000
|
+
* - both present and DIFFERENT → conflict: leave BOTH fields untouched so
|
|
2001
|
+
* the migration never silently chooses one; `validateJob`/`doctor` then flag the
|
|
2002
|
+
* pair and the routine stays paused rather than running against a guessed path.
|
|
2003
|
+
*
|
|
2004
|
+
* Idempotent: a file with only `cwd` (already migrated) is skipped.
|
|
2005
|
+
*/
|
|
2006
|
+
export function migrateRoutineRemoteCwdToCwd(routinesDir) {
|
|
2007
|
+
const dir = routinesDir ?? path.join(USER_DIR, 'routines');
|
|
2008
|
+
if (!fs.existsSync(dir))
|
|
2009
|
+
return;
|
|
2010
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
2011
|
+
let migrated = 0;
|
|
2012
|
+
let conflicts = 0;
|
|
2013
|
+
for (const file of files) {
|
|
2014
|
+
const filePath = path.join(dir, file);
|
|
2015
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
2016
|
+
let doc;
|
|
2017
|
+
try {
|
|
2018
|
+
doc = yaml.parse(raw);
|
|
2019
|
+
if (!doc || typeof doc !== 'object')
|
|
2020
|
+
continue;
|
|
2021
|
+
}
|
|
2022
|
+
catch {
|
|
2023
|
+
continue;
|
|
2024
|
+
}
|
|
2025
|
+
if (!('remoteCwd' in doc))
|
|
2026
|
+
continue;
|
|
2027
|
+
const remote = doc.remoteCwd;
|
|
2028
|
+
if (typeof remote !== 'string' || !remote.trim()) {
|
|
2029
|
+
// A malformed legacy value is not something to fold — drop it and move on.
|
|
2030
|
+
delete doc.remoteCwd;
|
|
2031
|
+
atomicWriteFileSync(filePath, yaml.stringify(doc));
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
if ('cwd' in doc) {
|
|
2035
|
+
if (doc.cwd === remote) {
|
|
2036
|
+
delete doc.remoteCwd; // duplicate — dedupe to the canonical field
|
|
2037
|
+
atomicWriteFileSync(filePath, yaml.stringify(doc));
|
|
2038
|
+
migrated++;
|
|
2039
|
+
}
|
|
2040
|
+
else {
|
|
2041
|
+
conflicts++; // leave both fields; validateJob/doctor pause the conflict
|
|
2042
|
+
}
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
delete doc.remoteCwd;
|
|
2046
|
+
doc.cwd = remote;
|
|
2047
|
+
atomicWriteFileSync(filePath, yaml.stringify(doc));
|
|
2048
|
+
migrated++;
|
|
2049
|
+
}
|
|
2050
|
+
if (migrated > 0) {
|
|
2051
|
+
console.error(`Migrated ${migrated} routine${migrated === 1 ? '' : 's'}: remoteCwd → cwd`);
|
|
2052
|
+
}
|
|
2053
|
+
if (conflicts > 0) {
|
|
2054
|
+
console.error(`${conflicts} routine${conflicts === 1 ? '' : 's'} have conflicting remoteCwd/cwd — left paused for manual repair (migration_conflict)`);
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
2057
|
+
/**
|
|
2058
|
+
* Pause every currently-active routine whose execution context no longer
|
|
2059
|
+
* resolves ready (RUSH-2290). An agent/workflow routine with no project/cwd, a
|
|
2060
|
+
* missing directory, or a non-portable path used to fire and fail every tick —
|
|
2061
|
+
* the mass auth_failed / untrusted-home storm this ticket exists to stop. After
|
|
2062
|
+
* the fold, such a routine is deactivated on THIS device (only), preventing it
|
|
2063
|
+
* from being scheduled until `agents routines doctor --all --fix` (or a repair +
|
|
2064
|
+
* `resume`) makes it ready. Never materializes a device manifest that does not
|
|
2065
|
+
* yet exist, and never touches command routines (they run in the target home).
|
|
2066
|
+
*/
|
|
2067
|
+
export function pauseUnreadyEnabledRoutines() {
|
|
2068
|
+
const enabled = enabledRoutineNames();
|
|
2069
|
+
if (enabled === null)
|
|
2070
|
+
return; // no manifest yet — nothing activated to pause
|
|
2071
|
+
const enabledSet = new Set(enabled);
|
|
2072
|
+
const paused = [];
|
|
2073
|
+
for (const job of listJobs()) {
|
|
2074
|
+
if (!enabledSet.has(job.name))
|
|
2075
|
+
continue;
|
|
2076
|
+
let ready = true;
|
|
2077
|
+
try {
|
|
2078
|
+
ready = validateJob(job).length === 0 && evaluateActivationReadiness(job).ready;
|
|
2079
|
+
}
|
|
2080
|
+
catch {
|
|
2081
|
+
ready = true; // never pause a routine because readiness itself threw
|
|
2082
|
+
}
|
|
2083
|
+
if (!ready)
|
|
2084
|
+
paused.push(job.name);
|
|
2085
|
+
}
|
|
2086
|
+
if (paused.length === 0)
|
|
2087
|
+
return;
|
|
2088
|
+
const pausedSet = new Set(paused);
|
|
2089
|
+
replaceEnabledRoutines(enabled.filter((name) => !pausedSet.has(name)));
|
|
2090
|
+
console.error(`Paused ${paused.length} routine${paused.length === 1 ? '' : 's'} with an unresolved execution context ` +
|
|
2091
|
+
`(run 'agents routines doctor --all' to see why): ${paused.join(', ')}`);
|
|
2092
|
+
}
|
|
1990
2093
|
/**
|
|
1991
2094
|
* Fold the legacy watchdog enable sentinel into the watchdog routine.
|
|
1992
2095
|
*
|
|
@@ -2212,6 +2315,8 @@ export async function runMigration() {
|
|
|
2212
2315
|
migrateExtrasExtrasToAgentsExtras();
|
|
2213
2316
|
// Rewrite routine YAML files: singular `device:` -> plural `devices: []`.
|
|
2214
2317
|
migrateRoutineDeviceToDevices();
|
|
2318
|
+
// Fold legacy host-placement `remoteCwd` into the canonical portable `cwd`.
|
|
2319
|
+
migrateRoutineRemoteCwdToCwd();
|
|
2215
2320
|
migrateLegacyRoutineActivation();
|
|
2216
2321
|
// These routines replace daemon timers that were always active. Devices with
|
|
2217
2322
|
// an existing activation manifest must retain that behavior after upgrade.
|
|
@@ -2220,6 +2325,11 @@ export async function runMigration() {
|
|
|
2220
2325
|
// who opted in under the old build stays opted in after upgrading. After the
|
|
2221
2326
|
// routine rewrites above so the routines dir is in its canonical shape.
|
|
2222
2327
|
migrateWatchdogSentinelToRoutine();
|
|
2328
|
+
// Deactivate any routine whose execution context no longer resolves ready, so
|
|
2329
|
+
// an anchor-less agent/workflow routine cannot keep firing-and-failing after the
|
|
2330
|
+
// fold (RUSH-2290). Runs AFTER the tick/watchdog routines are added so those
|
|
2331
|
+
// (command/home) routines are evaluated in their final shape.
|
|
2332
|
+
pauseUnreadyEnabledRoutines();
|
|
2223
2333
|
// Symlink repair runs LAST so it can find the post-move version homes.
|
|
2224
2334
|
repairAgentConfigSymlinks();
|
|
2225
2335
|
// Repair self-referential node_modules/.bin/<cli> symlinks (the droid
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Routine execution context + readiness resolution.
|
|
3
|
+
*
|
|
4
|
+
* A scheduled routine has to run *somewhere*. This module is the single,
|
|
5
|
+
* target-aware answer to "which directory does this routine's run land in, and
|
|
6
|
+
* is the chosen harness able to start there?" — computed for the eventual
|
|
7
|
+
* execution TARGET, never from the daemon process's own cwd.
|
|
8
|
+
*
|
|
9
|
+
* Two layers, both pure of global state (every input is injected, so a test
|
|
10
|
+
* exercises the real code path against real temp directories rather than a mock):
|
|
11
|
+
*
|
|
12
|
+
* - {@link resolveRoutineExecutionContext} — resolve the working directory from
|
|
13
|
+
* the routine's singular `project` anchor and/or portable `cwd`, following the
|
|
14
|
+
* locked resolution table (see below), and verify the structural + filesystem
|
|
15
|
+
* readiness of that directory (existence, portability, writability, cloud
|
|
16
|
+
* portability). This layer owns the *context* readiness codes.
|
|
17
|
+
* - {@link evaluateRoutineReadiness} — take a resolved context and layer the
|
|
18
|
+
* *harness/target* readiness codes (agent installed, Codex workspace trust,
|
|
19
|
+
* live auth, target reachability) via injected probes.
|
|
20
|
+
*
|
|
21
|
+
* Resolution table (target `$HOME` = the execution device's home):
|
|
22
|
+
*
|
|
23
|
+
* | project | cwd | resolved dir | readiness |
|
|
24
|
+
* |---------|----------------|-------------------------|-----------|
|
|
25
|
+
* | usable | — | project base | continue |
|
|
26
|
+
* | usable | relative | base + cwd (inside base)| continue if inside base + exists |
|
|
27
|
+
* | rootless| relative | $HOME + cwd | continue if exists |
|
|
28
|
+
* | — | relative | $HOME + cwd | continue if exists |
|
|
29
|
+
* | — | ~/… | $HOME-relative | continue if exists |
|
|
30
|
+
* | — | abs under home | normalized to ~/… | continue |
|
|
31
|
+
* | — | abs outside home| local-pinned only | pause (cwd_not_portable) for host/fleet/cloud |
|
|
32
|
+
* | named+unusable | — | no fallback | pause (project_path_missing) |
|
|
33
|
+
* | — | — (agent/workflow) | no implicit home | pause (execution_context_missing) |
|
|
34
|
+
* | — | — (command) | $HOME | continue (housekeeping) |
|
|
35
|
+
*/
|
|
36
|
+
/** Stable, machine-readable readiness codes. A routine is activated only when ready. */
|
|
37
|
+
export type RoutineReadinessCode = 'project_not_found' | 'project_path_missing' | 'cwd_missing' | 'cwd_not_directory' | 'cwd_not_portable' | 'execution_context_missing' | 'cloud_context_unsupported' | 'workspace_not_writable' | 'codex_workspace_untrusted' | 'agent_unavailable' | 'agent_auth_failed' | 'target_unreachable' | 'placement_unsupported' | 'migration_conflict';
|
|
38
|
+
export interface RoutineReadiness {
|
|
39
|
+
code: RoutineReadinessCode;
|
|
40
|
+
/** Human-readable one-line explanation of the failing check. */
|
|
41
|
+
message: string;
|
|
42
|
+
/** A single safe command that repairs the blocker, when one exists. */
|
|
43
|
+
repair?: string;
|
|
44
|
+
}
|
|
45
|
+
/** Where the routine body executes — mirrors {@link HostStrategy} placement. */
|
|
46
|
+
export type PlacementMode = 'local' | 'host' | 'fleet' | 'cloud';
|
|
47
|
+
/**
|
|
48
|
+
* What the caller resolved about the routine's singular `project` anchor.
|
|
49
|
+
* `undefined` (the field on the input) means the routine names no project.
|
|
50
|
+
*/
|
|
51
|
+
export type ProjectResolution = {
|
|
52
|
+
defined: false;
|
|
53
|
+
}
|
|
54
|
+
/** Defined project; `base` is its portable base dir (`~/…` or absolute), or
|
|
55
|
+
* undefined for a rootless Linear-imported project with no checkout. */
|
|
56
|
+
| {
|
|
57
|
+
defined: true;
|
|
58
|
+
base?: string;
|
|
59
|
+
};
|
|
60
|
+
export type RoutineKind = 'agent' | 'workflow' | 'command';
|
|
61
|
+
/** A filesystem probe against the execution TARGET. */
|
|
62
|
+
export interface ContextFsProbe {
|
|
63
|
+
exists(absPath: string): boolean;
|
|
64
|
+
isDirectory(absPath: string): boolean;
|
|
65
|
+
isWritable(absPath: string): boolean;
|
|
66
|
+
}
|
|
67
|
+
export interface ExecutionContextInput {
|
|
68
|
+
/** Routine name (for messages only). */
|
|
69
|
+
name?: string;
|
|
70
|
+
/** Singular execution anchor (`JobConfig.project`). */
|
|
71
|
+
project?: string;
|
|
72
|
+
/** Portable execution directory (`JobConfig.cwd`). */
|
|
73
|
+
cwd?: string;
|
|
74
|
+
/** Exactly one of agent/workflow/command determines the fallback rules. */
|
|
75
|
+
kind: RoutineKind;
|
|
76
|
+
/** Placement of the run — governs portability enforcement and cloud rules. */
|
|
77
|
+
mode: PlacementMode;
|
|
78
|
+
/** Execution target's absolute `$HOME`. Local: `os.homedir()`; remote: the target home. */
|
|
79
|
+
targetHome: string;
|
|
80
|
+
/** Resolution of the `project` anchor; omit when the routine names no project. */
|
|
81
|
+
projectResolution?: ProjectResolution;
|
|
82
|
+
/**
|
|
83
|
+
* Filesystem probe for the target, present only when this process can inspect
|
|
84
|
+
* it (a local run, or add/edit/doctor invoked on the target box). Absent for a
|
|
85
|
+
* remote/cloud target we cannot reach — then only structural + portability
|
|
86
|
+
* checks run (existence is deferred, never assumed).
|
|
87
|
+
*/
|
|
88
|
+
probe?: ContextFsProbe;
|
|
89
|
+
}
|
|
90
|
+
export interface ResolvedExecutionContext {
|
|
91
|
+
project?: string;
|
|
92
|
+
/** `config.cwd` echoed for the run record. */
|
|
93
|
+
requestedCwd?: string;
|
|
94
|
+
/** Portable resolved cwd for the run record: `~/…` when under target home, else absolute. */
|
|
95
|
+
resolvedCwd?: string;
|
|
96
|
+
/** The resolved cwd expanded to an absolute path on the target. Undefined when unresolved. */
|
|
97
|
+
absoluteCwd?: string;
|
|
98
|
+
targetHome: string;
|
|
99
|
+
ready: boolean;
|
|
100
|
+
/** Present when `ready` is false. */
|
|
101
|
+
readiness?: RoutineReadiness;
|
|
102
|
+
}
|
|
103
|
+
/** Expand a leading `~`/`$HOME` against the target home; pass other values through. */
|
|
104
|
+
export declare function expandTargetHome(home: string, p: string): string;
|
|
105
|
+
/** Rewrite an absolute path under the target home to its portable `~/…` form; pass others through. */
|
|
106
|
+
export declare function toTargetPortable(home: string, abs: string): string;
|
|
107
|
+
/** True for a bare relative path (not absolute, not home-anchored). */
|
|
108
|
+
export declare function isBareRelative(p: string): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the working directory a routine's run lands in and verify its
|
|
111
|
+
* structural + filesystem readiness for the given placement. Pure of global
|
|
112
|
+
* state — every dependency (target home, project resolution, filesystem probe)
|
|
113
|
+
* is injected.
|
|
114
|
+
*/
|
|
115
|
+
export declare function resolveRoutineExecutionContext(input: ExecutionContextInput): ResolvedExecutionContext;
|
|
116
|
+
/** Injected harness/target probes for {@link evaluateRoutineReadiness}. */
|
|
117
|
+
export interface HarnessReadinessProbes {
|
|
118
|
+
/** Is the resolved agent+version installed on the target? */
|
|
119
|
+
agentInstalled?(): boolean;
|
|
120
|
+
/** Is the absolute execution dir a trusted Codex workspace? (Codex agent only.) */
|
|
121
|
+
codexTrusted?(absoluteCwd: string): boolean;
|
|
122
|
+
/** Live auth verdict for the resolved account/version. `ok:false` → agent_auth_failed. */
|
|
123
|
+
authOk?(): {
|
|
124
|
+
ok: boolean;
|
|
125
|
+
reason?: string;
|
|
126
|
+
};
|
|
127
|
+
/** Is the execution target reachable? (host/fleet/cloud placement only.) */
|
|
128
|
+
targetReachable?(): boolean;
|
|
129
|
+
}
|
|
130
|
+
export interface RoutineReadinessResult {
|
|
131
|
+
context: ResolvedExecutionContext;
|
|
132
|
+
ready: boolean;
|
|
133
|
+
readiness?: RoutineReadiness;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Layer the harness/target readiness codes onto a resolved execution context.
|
|
137
|
+
* Context blockers short-circuit (no point probing auth for a routine that has
|
|
138
|
+
* no directory to run in). Every probe is optional and injected; an omitted
|
|
139
|
+
* probe is treated as "not applicable / passes" so a caller only pays for the
|
|
140
|
+
* checks it wires up.
|
|
141
|
+
*/
|
|
142
|
+
export declare function evaluateRoutineReadiness(context: ResolvedExecutionContext, probes?: HarnessReadinessProbes, opts?: {
|
|
143
|
+
agent?: string;
|
|
144
|
+
}): RoutineReadinessResult;
|